mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-01-31 09:58:19 -05:00
Compare commits
3 Commits
fix/autopi
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ee94d986c | ||
|
|
18a1661fa3 | ||
|
|
b72521daa9 |
@@ -54,7 +54,7 @@ Before proceeding with the installation, ensure your system meets the following
|
|||||||
### Updated Setup Instructions:
|
### Updated Setup Instructions:
|
||||||
We've moved to a fully maintained and regularly updated documentation site.
|
We've moved to a fully maintained and regularly updated documentation site.
|
||||||
|
|
||||||
👉 [Follow the official self-hosting guide here](https://docs.agpt.co/platform/getting-started/)
|
👉 [Follow the official self-hosting guide here](https://agpt.co/docs/platform/getting-started/getting-started)
|
||||||
|
|
||||||
|
|
||||||
This tutorial assumes you have Docker, VSCode, git and npm installed.
|
This tutorial assumes you have Docker, VSCode, git and npm installed.
|
||||||
|
|||||||
@@ -1834,6 +1834,11 @@ async def _execute_long_running_tool(
|
|||||||
tool_call_id=tool_call_id,
|
tool_call_id=tool_call_id,
|
||||||
result=error_response.model_dump_json(),
|
result=error_response.model_dump_json(),
|
||||||
)
|
)
|
||||||
|
# Generate LLM continuation so user sees explanation even for errors
|
||||||
|
try:
|
||||||
|
await _generate_llm_continuation(session_id=session_id, user_id=user_id)
|
||||||
|
except Exception as llm_err:
|
||||||
|
logger.warning(f"Failed to generate LLM continuation for error: {llm_err}")
|
||||||
finally:
|
finally:
|
||||||
await _mark_operation_completed(tool_call_id)
|
await _mark_operation_completed(tool_call_id)
|
||||||
|
|
||||||
|
|||||||
@@ -2,30 +2,54 @@
|
|||||||
|
|
||||||
from .core import (
|
from .core import (
|
||||||
AgentGeneratorNotConfiguredError,
|
AgentGeneratorNotConfiguredError,
|
||||||
|
AgentJsonValidationError,
|
||||||
|
AgentSummary,
|
||||||
|
DecompositionResult,
|
||||||
|
DecompositionStep,
|
||||||
|
LibraryAgentSummary,
|
||||||
|
MarketplaceAgentSummary,
|
||||||
decompose_goal,
|
decompose_goal,
|
||||||
|
enrich_library_agents_from_steps,
|
||||||
|
extract_search_terms_from_steps,
|
||||||
|
extract_uuids_from_text,
|
||||||
generate_agent,
|
generate_agent,
|
||||||
generate_agent_patch,
|
generate_agent_patch,
|
||||||
get_agent_as_json,
|
get_agent_as_json,
|
||||||
|
get_all_relevant_agents_for_generation,
|
||||||
|
get_library_agent_by_graph_id,
|
||||||
|
get_library_agent_by_id,
|
||||||
|
get_library_agents_for_generation,
|
||||||
json_to_graph,
|
json_to_graph,
|
||||||
save_agent_to_library,
|
save_agent_to_library,
|
||||||
|
search_marketplace_agents_for_generation,
|
||||||
)
|
)
|
||||||
from .errors import get_user_message_for_error
|
from .errors import get_user_message_for_error
|
||||||
from .service import health_check as check_external_service_health
|
from .service import health_check as check_external_service_health
|
||||||
from .service import is_external_service_configured
|
from .service import is_external_service_configured
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
# Core functions
|
"AgentGeneratorNotConfiguredError",
|
||||||
|
"AgentJsonValidationError",
|
||||||
|
"AgentSummary",
|
||||||
|
"DecompositionResult",
|
||||||
|
"DecompositionStep",
|
||||||
|
"LibraryAgentSummary",
|
||||||
|
"MarketplaceAgentSummary",
|
||||||
|
"check_external_service_health",
|
||||||
"decompose_goal",
|
"decompose_goal",
|
||||||
|
"enrich_library_agents_from_steps",
|
||||||
|
"extract_search_terms_from_steps",
|
||||||
|
"extract_uuids_from_text",
|
||||||
"generate_agent",
|
"generate_agent",
|
||||||
"generate_agent_patch",
|
"generate_agent_patch",
|
||||||
"save_agent_to_library",
|
|
||||||
"get_agent_as_json",
|
"get_agent_as_json",
|
||||||
"json_to_graph",
|
"get_all_relevant_agents_for_generation",
|
||||||
# Exceptions
|
"get_library_agent_by_graph_id",
|
||||||
"AgentGeneratorNotConfiguredError",
|
"get_library_agent_by_id",
|
||||||
# Service
|
"get_library_agents_for_generation",
|
||||||
"is_external_service_configured",
|
|
||||||
"check_external_service_health",
|
|
||||||
# Error handling
|
|
||||||
"get_user_message_for_error",
|
"get_user_message_for_error",
|
||||||
|
"is_external_service_configured",
|
||||||
|
"json_to_graph",
|
||||||
|
"save_agent_to_library",
|
||||||
|
"search_marketplace_agents_for_generation",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,11 +1,21 @@
|
|||||||
"""Core agent generation functions."""
|
"""Core agent generation functions."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import re
|
||||||
import uuid
|
import uuid
|
||||||
from typing import Any
|
from typing import Any, NotRequired, TypedDict
|
||||||
|
|
||||||
from backend.api.features.library import db as library_db
|
from backend.api.features.library import db as library_db
|
||||||
from backend.data.graph import Graph, Link, Node, create_graph
|
from backend.api.features.store import db as store_db
|
||||||
|
from backend.data.graph import (
|
||||||
|
Graph,
|
||||||
|
Link,
|
||||||
|
Node,
|
||||||
|
create_graph,
|
||||||
|
get_graph,
|
||||||
|
get_graph_all_versions,
|
||||||
|
)
|
||||||
|
from backend.util.exceptions import DatabaseError, NotFoundError
|
||||||
|
|
||||||
from .service import (
|
from .service import (
|
||||||
decompose_goal_external,
|
decompose_goal_external,
|
||||||
@@ -16,6 +26,74 @@ from .service import (
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
AGENT_EXECUTOR_BLOCK_ID = "e189baac-8c20-45a1-94a7-55177ea42565"
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutionSummary(TypedDict):
|
||||||
|
"""Summary of a single execution for quality assessment."""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
correctness_score: NotRequired[float]
|
||||||
|
activity_summary: NotRequired[str]
|
||||||
|
|
||||||
|
|
||||||
|
class LibraryAgentSummary(TypedDict):
|
||||||
|
"""Summary of a library agent for sub-agent composition.
|
||||||
|
|
||||||
|
Includes recent executions to help the LLM decide whether to use this agent.
|
||||||
|
Each execution shows status, correctness_score (0-1), and activity_summary.
|
||||||
|
"""
|
||||||
|
|
||||||
|
graph_id: str
|
||||||
|
graph_version: int
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
input_schema: dict[str, Any]
|
||||||
|
output_schema: dict[str, Any]
|
||||||
|
recent_executions: NotRequired[list[ExecutionSummary]]
|
||||||
|
|
||||||
|
|
||||||
|
class MarketplaceAgentSummary(TypedDict):
|
||||||
|
"""Summary of a marketplace agent for sub-agent composition."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
sub_heading: str
|
||||||
|
creator: str
|
||||||
|
is_marketplace_agent: bool
|
||||||
|
|
||||||
|
|
||||||
|
class DecompositionStep(TypedDict, total=False):
|
||||||
|
"""A single step in decomposed instructions."""
|
||||||
|
|
||||||
|
description: str
|
||||||
|
action: str
|
||||||
|
block_name: str
|
||||||
|
tool: str
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class DecompositionResult(TypedDict, total=False):
|
||||||
|
"""Result from decompose_goal - can be instructions, questions, or error."""
|
||||||
|
|
||||||
|
type: str
|
||||||
|
steps: list[DecompositionStep]
|
||||||
|
questions: list[dict[str, Any]]
|
||||||
|
error: str
|
||||||
|
error_type: str
|
||||||
|
|
||||||
|
|
||||||
|
AgentSummary = LibraryAgentSummary | MarketplaceAgentSummary | dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
def _to_dict_list(
|
||||||
|
agents: list[AgentSummary] | list[dict[str, Any]] | None,
|
||||||
|
) -> list[dict[str, Any]] | None:
|
||||||
|
"""Convert typed agent summaries to plain dicts for external service calls."""
|
||||||
|
if agents is None:
|
||||||
|
return None
|
||||||
|
return [dict(a) for a in agents]
|
||||||
|
|
||||||
|
|
||||||
class AgentGeneratorNotConfiguredError(Exception):
|
class AgentGeneratorNotConfiguredError(Exception):
|
||||||
"""Raised when the external Agent Generator service is not configured."""
|
"""Raised when the external Agent Generator service is not configured."""
|
||||||
@@ -36,15 +114,414 @@ def _check_service_configured() -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
async def decompose_goal(description: str, context: str = "") -> dict[str, Any] | None:
|
_UUID_PATTERN = re.compile(
|
||||||
|
r"[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def extract_uuids_from_text(text: str) -> list[str]:
|
||||||
|
"""Extract all UUID v4 strings from text.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
text: Text that may contain UUIDs (e.g., user's goal description)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of unique UUIDs found in the text (lowercase)
|
||||||
|
"""
|
||||||
|
matches = _UUID_PATTERN.findall(text)
|
||||||
|
return list({m.lower() for m in matches})
|
||||||
|
|
||||||
|
|
||||||
|
async def get_library_agent_by_id(
|
||||||
|
user_id: str, agent_id: str
|
||||||
|
) -> LibraryAgentSummary | None:
|
||||||
|
"""Fetch a specific library agent by its ID (library agent ID or graph_id).
|
||||||
|
|
||||||
|
This function tries multiple lookup strategies:
|
||||||
|
1. First tries to find by graph_id (AgentGraph primary key)
|
||||||
|
2. If not found, tries to find by library agent ID (LibraryAgent primary key)
|
||||||
|
|
||||||
|
This handles both cases:
|
||||||
|
- User provides graph_id (e.g., from AgentExecutorBlock)
|
||||||
|
- User provides library agent ID (e.g., from library URL)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user ID
|
||||||
|
agent_id: The ID to look up (can be graph_id or library agent ID)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
LibraryAgentSummary if found, None otherwise
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
agent = await library_db.get_library_agent_by_graph_id(user_id, agent_id)
|
||||||
|
if agent:
|
||||||
|
logger.debug(f"Found library agent by graph_id: {agent.name}")
|
||||||
|
return LibraryAgentSummary(
|
||||||
|
graph_id=agent.graph_id,
|
||||||
|
graph_version=agent.graph_version,
|
||||||
|
name=agent.name,
|
||||||
|
description=agent.description,
|
||||||
|
input_schema=agent.input_schema,
|
||||||
|
output_schema=agent.output_schema,
|
||||||
|
)
|
||||||
|
except DatabaseError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Could not fetch library agent by graph_id {agent_id}: {e}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
agent = await library_db.get_library_agent(agent_id, user_id)
|
||||||
|
if agent:
|
||||||
|
logger.debug(f"Found library agent by library_id: {agent.name}")
|
||||||
|
return LibraryAgentSummary(
|
||||||
|
graph_id=agent.graph_id,
|
||||||
|
graph_version=agent.graph_version,
|
||||||
|
name=agent.name,
|
||||||
|
description=agent.description,
|
||||||
|
input_schema=agent.input_schema,
|
||||||
|
output_schema=agent.output_schema,
|
||||||
|
)
|
||||||
|
except NotFoundError:
|
||||||
|
logger.debug(f"Library agent not found by library_id: {agent_id}")
|
||||||
|
except DatabaseError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Could not fetch library agent by library_id {agent_id}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
get_library_agent_by_graph_id = get_library_agent_by_id
|
||||||
|
|
||||||
|
|
||||||
|
async def get_library_agents_for_generation(
|
||||||
|
user_id: str,
|
||||||
|
search_query: str | None = None,
|
||||||
|
exclude_graph_id: str | None = None,
|
||||||
|
max_results: int = 15,
|
||||||
|
) -> list[LibraryAgentSummary]:
|
||||||
|
"""Fetch user's library agents formatted for Agent Generator.
|
||||||
|
|
||||||
|
Uses search-based fetching to return relevant agents instead of all agents.
|
||||||
|
This is more scalable for users with large libraries.
|
||||||
|
|
||||||
|
Includes recent_executions list to help the LLM assess agent quality:
|
||||||
|
- Each execution has status, correctness_score (0-1), and activity_summary
|
||||||
|
- This gives the LLM concrete examples of recent performance
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user ID
|
||||||
|
search_query: Optional search term to find relevant agents (user's goal/description)
|
||||||
|
exclude_graph_id: Optional graph ID to exclude (prevents circular references)
|
||||||
|
max_results: Maximum number of agents to return (default 15)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of LibraryAgentSummary with schemas and recent executions for sub-agent composition
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await library_db.list_library_agents(
|
||||||
|
user_id=user_id,
|
||||||
|
search_term=search_query,
|
||||||
|
page=1,
|
||||||
|
page_size=max_results,
|
||||||
|
include_executions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
results: list[LibraryAgentSummary] = []
|
||||||
|
for agent in response.agents:
|
||||||
|
if exclude_graph_id is not None and agent.graph_id == exclude_graph_id:
|
||||||
|
continue
|
||||||
|
|
||||||
|
summary = LibraryAgentSummary(
|
||||||
|
graph_id=agent.graph_id,
|
||||||
|
graph_version=agent.graph_version,
|
||||||
|
name=agent.name,
|
||||||
|
description=agent.description,
|
||||||
|
input_schema=agent.input_schema,
|
||||||
|
output_schema=agent.output_schema,
|
||||||
|
)
|
||||||
|
if agent.recent_executions:
|
||||||
|
exec_summaries: list[ExecutionSummary] = []
|
||||||
|
for ex in agent.recent_executions:
|
||||||
|
exec_sum = ExecutionSummary(status=ex.status)
|
||||||
|
if ex.correctness_score is not None:
|
||||||
|
exec_sum["correctness_score"] = ex.correctness_score
|
||||||
|
if ex.activity_summary:
|
||||||
|
exec_sum["activity_summary"] = ex.activity_summary
|
||||||
|
exec_summaries.append(exec_sum)
|
||||||
|
summary["recent_executions"] = exec_summaries
|
||||||
|
results.append(summary)
|
||||||
|
return results
|
||||||
|
except DatabaseError:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch library agents: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def search_marketplace_agents_for_generation(
|
||||||
|
search_query: str,
|
||||||
|
max_results: int = 10,
|
||||||
|
) -> list[MarketplaceAgentSummary]:
|
||||||
|
"""Search marketplace agents formatted for Agent Generator.
|
||||||
|
|
||||||
|
Note: This returns basic agent info. Full input/output schemas would require
|
||||||
|
additional graph fetches and is a potential future enhancement.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
search_query: Search term to find relevant public agents
|
||||||
|
max_results: Maximum number of agents to return (default 10)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of MarketplaceAgentSummary (without detailed schemas for now)
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await store_db.get_store_agents(
|
||||||
|
search_query=search_query,
|
||||||
|
page=1,
|
||||||
|
page_size=max_results,
|
||||||
|
)
|
||||||
|
|
||||||
|
results: list[MarketplaceAgentSummary] = []
|
||||||
|
for agent in response.agents:
|
||||||
|
results.append(
|
||||||
|
MarketplaceAgentSummary(
|
||||||
|
name=agent.agent_name,
|
||||||
|
description=agent.description,
|
||||||
|
sub_heading=agent.sub_heading,
|
||||||
|
creator=agent.creator,
|
||||||
|
is_marketplace_agent=True,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return results
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to search marketplace agents: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
async def get_all_relevant_agents_for_generation(
|
||||||
|
user_id: str,
|
||||||
|
search_query: str | None = None,
|
||||||
|
exclude_graph_id: str | None = None,
|
||||||
|
include_library: bool = True,
|
||||||
|
include_marketplace: bool = True,
|
||||||
|
max_library_results: int = 15,
|
||||||
|
max_marketplace_results: int = 10,
|
||||||
|
) -> list[AgentSummary]:
|
||||||
|
"""Fetch relevant agents from library and/or marketplace.
|
||||||
|
|
||||||
|
Searches both user's library and marketplace by default.
|
||||||
|
Explicitly mentioned UUIDs in the search query are always looked up.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user ID
|
||||||
|
search_query: Search term to find relevant agents (user's goal/description)
|
||||||
|
exclude_graph_id: Optional graph ID to exclude (prevents circular references)
|
||||||
|
include_library: Whether to search user's library (default True)
|
||||||
|
include_marketplace: Whether to also search marketplace (default True)
|
||||||
|
max_library_results: Max library agents to return (default 15)
|
||||||
|
max_marketplace_results: Max marketplace agents to return (default 10)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of AgentSummary, library agents first (with full schemas),
|
||||||
|
then marketplace agents (basic info only)
|
||||||
|
"""
|
||||||
|
agents: list[AgentSummary] = []
|
||||||
|
seen_graph_ids: set[str] = set()
|
||||||
|
|
||||||
|
if search_query:
|
||||||
|
mentioned_uuids = extract_uuids_from_text(search_query)
|
||||||
|
for graph_id in mentioned_uuids:
|
||||||
|
if graph_id == exclude_graph_id:
|
||||||
|
continue
|
||||||
|
agent = await get_library_agent_by_graph_id(user_id, graph_id)
|
||||||
|
agent_graph_id = agent.get("graph_id") if agent else None
|
||||||
|
if agent and agent_graph_id and agent_graph_id not in seen_graph_ids:
|
||||||
|
agents.append(agent)
|
||||||
|
seen_graph_ids.add(agent_graph_id)
|
||||||
|
logger.debug(
|
||||||
|
f"Found explicitly mentioned agent: {agent.get('name') or 'Unknown'}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if include_library:
|
||||||
|
library_agents = await get_library_agents_for_generation(
|
||||||
|
user_id=user_id,
|
||||||
|
search_query=search_query,
|
||||||
|
exclude_graph_id=exclude_graph_id,
|
||||||
|
max_results=max_library_results,
|
||||||
|
)
|
||||||
|
for agent in library_agents:
|
||||||
|
graph_id = agent.get("graph_id")
|
||||||
|
if graph_id and graph_id not in seen_graph_ids:
|
||||||
|
agents.append(agent)
|
||||||
|
seen_graph_ids.add(graph_id)
|
||||||
|
|
||||||
|
if include_marketplace and search_query:
|
||||||
|
marketplace_agents = await search_marketplace_agents_for_generation(
|
||||||
|
search_query=search_query,
|
||||||
|
max_results=max_marketplace_results,
|
||||||
|
)
|
||||||
|
library_names: set[str] = set()
|
||||||
|
for a in agents:
|
||||||
|
name = a.get("name")
|
||||||
|
if name and isinstance(name, str):
|
||||||
|
library_names.add(name.lower())
|
||||||
|
for agent in marketplace_agents:
|
||||||
|
agent_name = agent.get("name")
|
||||||
|
if agent_name and isinstance(agent_name, str):
|
||||||
|
if agent_name.lower() not in library_names:
|
||||||
|
agents.append(agent)
|
||||||
|
|
||||||
|
return agents
|
||||||
|
|
||||||
|
|
||||||
|
def extract_search_terms_from_steps(
|
||||||
|
decomposition_result: DecompositionResult | dict[str, Any],
|
||||||
|
) -> list[str]:
|
||||||
|
"""Extract search terms from decomposed instruction steps.
|
||||||
|
|
||||||
|
Analyzes the decomposition result to extract relevant keywords
|
||||||
|
for additional library agent searches.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
decomposition_result: Result from decompose_goal containing steps
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of unique search terms extracted from steps
|
||||||
|
"""
|
||||||
|
search_terms: list[str] = []
|
||||||
|
|
||||||
|
if decomposition_result.get("type") != "instructions":
|
||||||
|
return search_terms
|
||||||
|
|
||||||
|
steps = decomposition_result.get("steps", [])
|
||||||
|
if not steps:
|
||||||
|
return search_terms
|
||||||
|
|
||||||
|
step_keys: list[str] = ["description", "action", "block_name", "tool", "name"]
|
||||||
|
|
||||||
|
for step in steps:
|
||||||
|
for key in step_keys:
|
||||||
|
value = step.get(key) # type: ignore[union-attr]
|
||||||
|
if isinstance(value, str) and len(value) > 3:
|
||||||
|
search_terms.append(value)
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
unique_terms: list[str] = []
|
||||||
|
for term in search_terms:
|
||||||
|
term_lower = term.lower()
|
||||||
|
if term_lower not in seen:
|
||||||
|
seen.add(term_lower)
|
||||||
|
unique_terms.append(term)
|
||||||
|
|
||||||
|
return unique_terms
|
||||||
|
|
||||||
|
|
||||||
|
async def enrich_library_agents_from_steps(
|
||||||
|
user_id: str,
|
||||||
|
decomposition_result: DecompositionResult | dict[str, Any],
|
||||||
|
existing_agents: list[AgentSummary] | list[dict[str, Any]],
|
||||||
|
exclude_graph_id: str | None = None,
|
||||||
|
include_marketplace: bool = True,
|
||||||
|
max_additional_results: int = 10,
|
||||||
|
) -> list[AgentSummary] | list[dict[str, Any]]:
|
||||||
|
"""Enrich library agents list with additional searches based on decomposed steps.
|
||||||
|
|
||||||
|
This implements two-phase search: after decomposition, we search for additional
|
||||||
|
relevant agents based on the specific steps identified.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user ID
|
||||||
|
decomposition_result: Result from decompose_goal containing steps
|
||||||
|
existing_agents: Already fetched library agents from initial search
|
||||||
|
exclude_graph_id: Optional graph ID to exclude
|
||||||
|
include_marketplace: Whether to also search marketplace
|
||||||
|
max_additional_results: Max additional agents per search term (default 10)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Combined list of library agents (existing + newly discovered)
|
||||||
|
"""
|
||||||
|
search_terms = extract_search_terms_from_steps(decomposition_result)
|
||||||
|
|
||||||
|
if not search_terms:
|
||||||
|
return existing_agents
|
||||||
|
|
||||||
|
existing_ids: set[str] = set()
|
||||||
|
existing_names: set[str] = set()
|
||||||
|
|
||||||
|
for agent in existing_agents:
|
||||||
|
agent_name = agent.get("name")
|
||||||
|
if agent_name and isinstance(agent_name, str):
|
||||||
|
existing_names.add(agent_name.lower())
|
||||||
|
graph_id = agent.get("graph_id") # type: ignore[call-overload]
|
||||||
|
if graph_id and isinstance(graph_id, str):
|
||||||
|
existing_ids.add(graph_id)
|
||||||
|
|
||||||
|
all_agents: list[AgentSummary] | list[dict[str, Any]] = list(existing_agents)
|
||||||
|
|
||||||
|
for term in search_terms[:3]:
|
||||||
|
try:
|
||||||
|
additional_agents = await get_all_relevant_agents_for_generation(
|
||||||
|
user_id=user_id,
|
||||||
|
search_query=term,
|
||||||
|
exclude_graph_id=exclude_graph_id,
|
||||||
|
include_marketplace=include_marketplace,
|
||||||
|
max_library_results=max_additional_results,
|
||||||
|
max_marketplace_results=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
for agent in additional_agents:
|
||||||
|
agent_name = agent.get("name")
|
||||||
|
if not agent_name or not isinstance(agent_name, str):
|
||||||
|
continue
|
||||||
|
agent_name_lower = agent_name.lower()
|
||||||
|
|
||||||
|
if agent_name_lower in existing_names:
|
||||||
|
continue
|
||||||
|
|
||||||
|
graph_id = agent.get("graph_id") # type: ignore[call-overload]
|
||||||
|
if graph_id and graph_id in existing_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
all_agents.append(agent)
|
||||||
|
existing_names.add(agent_name_lower)
|
||||||
|
if graph_id and isinstance(graph_id, str):
|
||||||
|
existing_ids.add(graph_id)
|
||||||
|
|
||||||
|
except DatabaseError:
|
||||||
|
logger.error(f"Database error searching for agents with term '{term}'")
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to search for additional agents with term '{term}': {e}"
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.debug(
|
||||||
|
f"Enriched library agents: {len(existing_agents)} initial + "
|
||||||
|
f"{len(all_agents) - len(existing_agents)} additional = {len(all_agents)} total"
|
||||||
|
)
|
||||||
|
|
||||||
|
return all_agents
|
||||||
|
|
||||||
|
|
||||||
|
async def decompose_goal(
|
||||||
|
description: str,
|
||||||
|
context: str = "",
|
||||||
|
library_agents: list[AgentSummary] | None = None,
|
||||||
|
) -> DecompositionResult | None:
|
||||||
"""Break down a goal into steps or return clarifying questions.
|
"""Break down a goal into steps or return clarifying questions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
description: Natural language goal description
|
description: Natural language goal description
|
||||||
context: Additional context (e.g., answers to previous questions)
|
context: Additional context (e.g., answers to previous questions)
|
||||||
|
library_agents: User's library agents available for sub-agent composition
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with either:
|
DecompositionResult with either:
|
||||||
- {"type": "clarifying_questions", "questions": [...]}
|
- {"type": "clarifying_questions", "questions": [...]}
|
||||||
- {"type": "instructions", "steps": [...]}
|
- {"type": "instructions", "steps": [...]}
|
||||||
Or None on error
|
Or None on error
|
||||||
@@ -54,14 +531,21 @@ async def decompose_goal(description: str, context: str = "") -> dict[str, Any]
|
|||||||
"""
|
"""
|
||||||
_check_service_configured()
|
_check_service_configured()
|
||||||
logger.info("Calling external Agent Generator service for decompose_goal")
|
logger.info("Calling external Agent Generator service for decompose_goal")
|
||||||
return await decompose_goal_external(description, context)
|
result = await decompose_goal_external(
|
||||||
|
description, context, _to_dict_list(library_agents)
|
||||||
|
)
|
||||||
|
return result # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
||||||
async def generate_agent(instructions: dict[str, Any]) -> dict[str, Any] | None:
|
async def generate_agent(
|
||||||
|
instructions: DecompositionResult | dict[str, Any],
|
||||||
|
library_agents: list[AgentSummary] | list[dict[str, Any]] | None = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
"""Generate agent JSON from instructions.
|
"""Generate agent JSON from instructions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instructions: Structured instructions from decompose_goal
|
instructions: Structured instructions from decompose_goal
|
||||||
|
library_agents: User's library agents available for sub-agent composition
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Agent JSON dict, error dict {"type": "error", ...}, or None on error
|
Agent JSON dict, error dict {"type": "error", ...}, or None on error
|
||||||
@@ -71,12 +555,12 @@ async def generate_agent(instructions: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
"""
|
"""
|
||||||
_check_service_configured()
|
_check_service_configured()
|
||||||
logger.info("Calling external Agent Generator service for generate_agent")
|
logger.info("Calling external Agent Generator service for generate_agent")
|
||||||
result = await generate_agent_external(instructions)
|
result = await generate_agent_external(
|
||||||
|
dict(instructions), _to_dict_list(library_agents)
|
||||||
|
)
|
||||||
if result:
|
if result:
|
||||||
# Check if it's an error response - pass through as-is
|
|
||||||
if isinstance(result, dict) and result.get("type") == "error":
|
if isinstance(result, dict) and result.get("type") == "error":
|
||||||
return result
|
return result
|
||||||
# Ensure required fields for successful agent generation
|
|
||||||
if "id" not in result:
|
if "id" not in result:
|
||||||
result["id"] = str(uuid.uuid4())
|
result["id"] = str(uuid.uuid4())
|
||||||
if "version" not in result:
|
if "version" not in result:
|
||||||
@@ -86,6 +570,12 @@ async def generate_agent(instructions: dict[str, Any]) -> dict[str, Any] | None:
|
|||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class AgentJsonValidationError(Exception):
|
||||||
|
"""Raised when agent JSON is invalid or missing required fields."""
|
||||||
|
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def json_to_graph(agent_json: dict[str, Any]) -> Graph:
|
def json_to_graph(agent_json: dict[str, Any]) -> Graph:
|
||||||
"""Convert agent JSON dict to Graph model.
|
"""Convert agent JSON dict to Graph model.
|
||||||
|
|
||||||
@@ -94,25 +584,55 @@ def json_to_graph(agent_json: dict[str, Any]) -> Graph:
|
|||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Graph ready for saving
|
Graph ready for saving
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
AgentJsonValidationError: If required fields are missing from nodes or links
|
||||||
"""
|
"""
|
||||||
nodes = []
|
nodes = []
|
||||||
for n in agent_json.get("nodes", []):
|
for idx, n in enumerate(agent_json.get("nodes", [])):
|
||||||
|
block_id = n.get("block_id")
|
||||||
|
if not block_id:
|
||||||
|
node_id = n.get("id", f"index_{idx}")
|
||||||
|
raise AgentJsonValidationError(
|
||||||
|
f"Node '{node_id}' is missing required field 'block_id'"
|
||||||
|
)
|
||||||
node = Node(
|
node = Node(
|
||||||
id=n.get("id", str(uuid.uuid4())),
|
id=n.get("id", str(uuid.uuid4())),
|
||||||
block_id=n["block_id"],
|
block_id=block_id,
|
||||||
input_default=n.get("input_default", {}),
|
input_default=n.get("input_default", {}),
|
||||||
metadata=n.get("metadata", {}),
|
metadata=n.get("metadata", {}),
|
||||||
)
|
)
|
||||||
nodes.append(node)
|
nodes.append(node)
|
||||||
|
|
||||||
links = []
|
links = []
|
||||||
for link_data in agent_json.get("links", []):
|
for idx, link_data in enumerate(agent_json.get("links", [])):
|
||||||
|
source_id = link_data.get("source_id")
|
||||||
|
sink_id = link_data.get("sink_id")
|
||||||
|
source_name = link_data.get("source_name")
|
||||||
|
sink_name = link_data.get("sink_name")
|
||||||
|
|
||||||
|
missing_fields = []
|
||||||
|
if not source_id:
|
||||||
|
missing_fields.append("source_id")
|
||||||
|
if not sink_id:
|
||||||
|
missing_fields.append("sink_id")
|
||||||
|
if not source_name:
|
||||||
|
missing_fields.append("source_name")
|
||||||
|
if not sink_name:
|
||||||
|
missing_fields.append("sink_name")
|
||||||
|
|
||||||
|
if missing_fields:
|
||||||
|
link_id = link_data.get("id", f"index_{idx}")
|
||||||
|
raise AgentJsonValidationError(
|
||||||
|
f"Link '{link_id}' is missing required fields: {', '.join(missing_fields)}"
|
||||||
|
)
|
||||||
|
|
||||||
link = Link(
|
link = Link(
|
||||||
id=link_data.get("id", str(uuid.uuid4())),
|
id=link_data.get("id", str(uuid.uuid4())),
|
||||||
source_id=link_data["source_id"],
|
source_id=source_id,
|
||||||
sink_id=link_data["sink_id"],
|
sink_id=sink_id,
|
||||||
source_name=link_data["source_name"],
|
source_name=source_name,
|
||||||
sink_name=link_data["sink_name"],
|
sink_name=sink_name,
|
||||||
is_static=link_data.get("is_static", False),
|
is_static=link_data.get("is_static", False),
|
||||||
)
|
)
|
||||||
links.append(link)
|
links.append(link)
|
||||||
@@ -133,22 +653,40 @@ def _reassign_node_ids(graph: Graph) -> None:
|
|||||||
|
|
||||||
This is needed when creating a new version to avoid unique constraint violations.
|
This is needed when creating a new version to avoid unique constraint violations.
|
||||||
"""
|
"""
|
||||||
# Create mapping from old node IDs to new UUIDs
|
|
||||||
id_map = {node.id: str(uuid.uuid4()) for node in graph.nodes}
|
id_map = {node.id: str(uuid.uuid4()) for node in graph.nodes}
|
||||||
|
|
||||||
# Reassign node IDs
|
|
||||||
for node in graph.nodes:
|
for node in graph.nodes:
|
||||||
node.id = id_map[node.id]
|
node.id = id_map[node.id]
|
||||||
|
|
||||||
# Update link references to use new node IDs
|
|
||||||
for link in graph.links:
|
for link in graph.links:
|
||||||
link.id = str(uuid.uuid4()) # Also give links new IDs
|
link.id = str(uuid.uuid4())
|
||||||
if link.source_id in id_map:
|
if link.source_id in id_map:
|
||||||
link.source_id = id_map[link.source_id]
|
link.source_id = id_map[link.source_id]
|
||||||
if link.sink_id in id_map:
|
if link.sink_id in id_map:
|
||||||
link.sink_id = id_map[link.sink_id]
|
link.sink_id = id_map[link.sink_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _populate_agent_executor_user_ids(agent_json: dict[str, Any], user_id: str) -> None:
|
||||||
|
"""Populate user_id in AgentExecutorBlock nodes.
|
||||||
|
|
||||||
|
The external agent generator creates AgentExecutorBlock nodes with empty user_id.
|
||||||
|
This function fills in the actual user_id so sub-agents run with correct permissions.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
agent_json: Agent JSON dict (modified in place)
|
||||||
|
user_id: User ID to set
|
||||||
|
"""
|
||||||
|
for node in agent_json.get("nodes", []):
|
||||||
|
if node.get("block_id") == AGENT_EXECUTOR_BLOCK_ID:
|
||||||
|
input_default = node.get("input_default") or {}
|
||||||
|
if not input_default.get("user_id"):
|
||||||
|
input_default["user_id"] = user_id
|
||||||
|
node["input_default"] = input_default
|
||||||
|
logger.debug(
|
||||||
|
f"Set user_id for AgentExecutorBlock node {node.get('id')}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def save_agent_to_library(
|
async def save_agent_to_library(
|
||||||
agent_json: dict[str, Any], user_id: str, is_update: bool = False
|
agent_json: dict[str, Any], user_id: str, is_update: bool = False
|
||||||
) -> tuple[Graph, Any]:
|
) -> tuple[Graph, Any]:
|
||||||
@@ -162,33 +700,27 @@ async def save_agent_to_library(
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of (created Graph, LibraryAgent)
|
Tuple of (created Graph, LibraryAgent)
|
||||||
"""
|
"""
|
||||||
from backend.data.graph import get_graph_all_versions
|
# Populate user_id in AgentExecutorBlock nodes before conversion
|
||||||
|
_populate_agent_executor_user_ids(agent_json, user_id)
|
||||||
|
|
||||||
graph = json_to_graph(agent_json)
|
graph = json_to_graph(agent_json)
|
||||||
|
|
||||||
if is_update:
|
if is_update:
|
||||||
# For updates, keep the same graph ID but increment version
|
|
||||||
# and reassign node/link IDs to avoid conflicts
|
|
||||||
if graph.id:
|
if graph.id:
|
||||||
existing_versions = await get_graph_all_versions(graph.id, user_id)
|
existing_versions = await get_graph_all_versions(graph.id, user_id)
|
||||||
if existing_versions:
|
if existing_versions:
|
||||||
latest_version = max(v.version for v in existing_versions)
|
latest_version = max(v.version for v in existing_versions)
|
||||||
graph.version = latest_version + 1
|
graph.version = latest_version + 1
|
||||||
# Reassign node IDs (but keep graph ID the same)
|
|
||||||
_reassign_node_ids(graph)
|
_reassign_node_ids(graph)
|
||||||
logger.info(f"Updating agent {graph.id} to version {graph.version}")
|
logger.info(f"Updating agent {graph.id} to version {graph.version}")
|
||||||
else:
|
else:
|
||||||
# For new agents, always generate a fresh UUID to avoid collisions
|
|
||||||
graph.id = str(uuid.uuid4())
|
graph.id = str(uuid.uuid4())
|
||||||
graph.version = 1
|
graph.version = 1
|
||||||
# Reassign all node IDs as well
|
|
||||||
_reassign_node_ids(graph)
|
_reassign_node_ids(graph)
|
||||||
logger.info(f"Creating new agent with ID {graph.id}")
|
logger.info(f"Creating new agent with ID {graph.id}")
|
||||||
|
|
||||||
# Save to database
|
|
||||||
created_graph = await create_graph(graph, user_id)
|
created_graph = await create_graph(graph, user_id)
|
||||||
|
|
||||||
# Add to user's library (or update existing library agent)
|
|
||||||
library_agents = await library_db.create_library_agent(
|
library_agents = await library_db.create_library_agent(
|
||||||
graph=created_graph,
|
graph=created_graph,
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -200,25 +732,31 @@ async def save_agent_to_library(
|
|||||||
|
|
||||||
|
|
||||||
async def get_agent_as_json(
|
async def get_agent_as_json(
|
||||||
graph_id: str, user_id: str | None
|
agent_id: str, user_id: str | None
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Fetch an agent and convert to JSON format for editing.
|
"""Fetch an agent and convert to JSON format for editing.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
graph_id: Graph ID or library agent ID
|
agent_id: Graph ID or library agent ID
|
||||||
user_id: User ID
|
user_id: User ID
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Agent as JSON dict or None if not found
|
Agent as JSON dict or None if not found
|
||||||
"""
|
"""
|
||||||
from backend.data.graph import get_graph
|
graph = await get_graph(agent_id, version=None, user_id=user_id)
|
||||||
|
|
||||||
|
if not graph and user_id:
|
||||||
|
try:
|
||||||
|
library_agent = await library_db.get_library_agent(agent_id, user_id)
|
||||||
|
graph = await get_graph(
|
||||||
|
library_agent.graph_id, version=None, user_id=user_id
|
||||||
|
)
|
||||||
|
except NotFoundError:
|
||||||
|
pass
|
||||||
|
|
||||||
# Try to get the graph (version=None gets the active version)
|
|
||||||
graph = await get_graph(graph_id, version=None, user_id=user_id)
|
|
||||||
if not graph:
|
if not graph:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Convert to JSON format
|
|
||||||
nodes = []
|
nodes = []
|
||||||
for node in graph.nodes:
|
for node in graph.nodes:
|
||||||
nodes.append(
|
nodes.append(
|
||||||
@@ -256,7 +794,9 @@ async def get_agent_as_json(
|
|||||||
|
|
||||||
|
|
||||||
async def generate_agent_patch(
|
async def generate_agent_patch(
|
||||||
update_request: str, current_agent: dict[str, Any]
|
update_request: str,
|
||||||
|
current_agent: dict[str, Any],
|
||||||
|
library_agents: list[AgentSummary] | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Update an existing agent using natural language.
|
"""Update an existing agent using natural language.
|
||||||
|
|
||||||
@@ -268,6 +808,7 @@ async def generate_agent_patch(
|
|||||||
Args:
|
Args:
|
||||||
update_request: Natural language description of changes
|
update_request: Natural language description of changes
|
||||||
current_agent: Current agent JSON
|
current_agent: Current agent JSON
|
||||||
|
library_agents: User's library agents available for sub-agent composition
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated agent JSON, clarifying questions dict {"type": "clarifying_questions", ...},
|
Updated agent JSON, clarifying questions dict {"type": "clarifying_questions", ...},
|
||||||
@@ -278,4 +819,6 @@ async def generate_agent_patch(
|
|||||||
"""
|
"""
|
||||||
_check_service_configured()
|
_check_service_configured()
|
||||||
logger.info("Calling external Agent Generator service for generate_agent_patch")
|
logger.info("Calling external Agent Generator service for generate_agent_patch")
|
||||||
return await generate_agent_patch_external(update_request, current_agent)
|
return await generate_agent_patch_external(
|
||||||
|
update_request, current_agent, _to_dict_list(library_agents)
|
||||||
|
)
|
||||||
|
|||||||
@@ -1,11 +1,43 @@
|
|||||||
"""Error handling utilities for agent generator."""
|
"""Error handling utilities for agent generator."""
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def _sanitize_error_details(details: str) -> str:
|
||||||
|
"""Sanitize error details to remove sensitive information.
|
||||||
|
|
||||||
|
Strips common patterns that could expose internal system info:
|
||||||
|
- File paths (Unix and Windows)
|
||||||
|
- Database connection strings
|
||||||
|
- URLs with credentials
|
||||||
|
- Stack trace internals
|
||||||
|
|
||||||
|
Args:
|
||||||
|
details: Raw error details string
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized error details safe for user display
|
||||||
|
"""
|
||||||
|
sanitized = re.sub(
|
||||||
|
r"/[a-zA-Z0-9_./\-]+\.(py|js|ts|json|yaml|yml)", "[path]", details
|
||||||
|
)
|
||||||
|
sanitized = re.sub(r"[A-Z]:\\[a-zA-Z0-9_\\.\\-]+", "[path]", sanitized)
|
||||||
|
sanitized = re.sub(
|
||||||
|
r"(postgres|mysql|mongodb|redis)://[^\s]+", "[database_url]", sanitized
|
||||||
|
)
|
||||||
|
sanitized = re.sub(r"https?://[^:]+:[^@]+@[^\s]+", "[url]", sanitized)
|
||||||
|
sanitized = re.sub(r", line \d+", "", sanitized)
|
||||||
|
sanitized = re.sub(r'File "[^"]+",?', "", sanitized)
|
||||||
|
|
||||||
|
return sanitized.strip()
|
||||||
|
|
||||||
|
|
||||||
def get_user_message_for_error(
|
def get_user_message_for_error(
|
||||||
error_type: str,
|
error_type: str,
|
||||||
operation: str = "process the request",
|
operation: str = "process the request",
|
||||||
llm_parse_message: str | None = None,
|
llm_parse_message: str | None = None,
|
||||||
validation_message: str | None = None,
|
validation_message: str | None = None,
|
||||||
|
error_details: str | None = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Get a user-friendly error message based on error type.
|
"""Get a user-friendly error message based on error type.
|
||||||
|
|
||||||
@@ -19,25 +51,45 @@ def get_user_message_for_error(
|
|||||||
message (e.g., "analyze the goal", "generate the agent")
|
message (e.g., "analyze the goal", "generate the agent")
|
||||||
llm_parse_message: Custom message for llm_parse_error type
|
llm_parse_message: Custom message for llm_parse_error type
|
||||||
validation_message: Custom message for validation_error type
|
validation_message: Custom message for validation_error type
|
||||||
|
error_details: Optional additional details about the error
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
User-friendly error message suitable for display to the user
|
User-friendly error message suitable for display to the user
|
||||||
"""
|
"""
|
||||||
|
base_message = ""
|
||||||
|
|
||||||
if error_type == "llm_parse_error":
|
if error_type == "llm_parse_error":
|
||||||
return (
|
base_message = (
|
||||||
llm_parse_message
|
llm_parse_message
|
||||||
or "The AI had trouble processing this request. Please try again."
|
or "The AI had trouble processing this request. Please try again."
|
||||||
)
|
)
|
||||||
elif error_type == "validation_error":
|
elif error_type == "validation_error":
|
||||||
return (
|
base_message = (
|
||||||
validation_message
|
validation_message
|
||||||
or "The request failed validation. Please try rephrasing."
|
or "The generated agent failed validation. "
|
||||||
|
"This usually happens when the agent structure doesn't match "
|
||||||
|
"what the platform expects. Please try simplifying your goal "
|
||||||
|
"or breaking it into smaller parts."
|
||||||
)
|
)
|
||||||
elif error_type == "patch_error":
|
elif error_type == "patch_error":
|
||||||
return "Failed to apply the changes. Please try a different approach."
|
base_message = (
|
||||||
|
"Failed to apply the changes. The modification couldn't be "
|
||||||
|
"validated. Please try a different approach or simplify the change."
|
||||||
|
)
|
||||||
elif error_type in ("timeout", "llm_timeout"):
|
elif error_type in ("timeout", "llm_timeout"):
|
||||||
return "The request took too long. Please try again."
|
base_message = (
|
||||||
|
"The request took too long to process. This can happen with "
|
||||||
|
"complex agents. Please try again or simplify your goal."
|
||||||
|
)
|
||||||
elif error_type in ("rate_limit", "llm_rate_limit"):
|
elif error_type in ("rate_limit", "llm_rate_limit"):
|
||||||
return "The service is currently busy. Please try again in a moment."
|
base_message = "The service is currently busy. Please try again in a moment."
|
||||||
else:
|
else:
|
||||||
return f"Failed to {operation}. Please try again."
|
base_message = f"Failed to {operation}. Please try again."
|
||||||
|
|
||||||
|
if error_details:
|
||||||
|
details = _sanitize_error_details(error_details)
|
||||||
|
if len(details) > 200:
|
||||||
|
details = details[:200] + "..."
|
||||||
|
base_message += f"\n\nTechnical details: {details}"
|
||||||
|
|
||||||
|
return base_message
|
||||||
|
|||||||
@@ -117,13 +117,16 @@ def _get_client() -> httpx.AsyncClient:
|
|||||||
|
|
||||||
|
|
||||||
async def decompose_goal_external(
|
async def decompose_goal_external(
|
||||||
description: str, context: str = ""
|
description: str,
|
||||||
|
context: str = "",
|
||||||
|
library_agents: list[dict[str, Any]] | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Call the external service to decompose a goal.
|
"""Call the external service to decompose a goal.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
description: Natural language goal description
|
description: Natural language goal description
|
||||||
context: Additional context (e.g., answers to previous questions)
|
context: Additional context (e.g., answers to previous questions)
|
||||||
|
library_agents: User's library agents available for sub-agent composition
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Dict with either:
|
Dict with either:
|
||||||
@@ -141,6 +144,8 @@ async def decompose_goal_external(
|
|||||||
if context:
|
if context:
|
||||||
# The external service uses user_instruction for additional context
|
# The external service uses user_instruction for additional context
|
||||||
payload["user_instruction"] = context
|
payload["user_instruction"] = context
|
||||||
|
if library_agents:
|
||||||
|
payload["library_agents"] = library_agents
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await client.post("/api/decompose-description", json=payload)
|
response = await client.post("/api/decompose-description", json=payload)
|
||||||
@@ -207,21 +212,25 @@ async def decompose_goal_external(
|
|||||||
|
|
||||||
async def generate_agent_external(
|
async def generate_agent_external(
|
||||||
instructions: dict[str, Any],
|
instructions: dict[str, Any],
|
||||||
|
library_agents: list[dict[str, Any]] | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Call the external service to generate an agent from instructions.
|
"""Call the external service to generate an agent from instructions.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
instructions: Structured instructions from decompose_goal
|
instructions: Structured instructions from decompose_goal
|
||||||
|
library_agents: User's library agents available for sub-agent composition
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Agent JSON dict on success, or error dict {"type": "error", ...} on error
|
Agent JSON dict on success, or error dict {"type": "error", ...} on error
|
||||||
"""
|
"""
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {"instructions": instructions}
|
||||||
|
if library_agents:
|
||||||
|
payload["library_agents"] = library_agents
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await client.post(
|
response = await client.post("/api/generate-agent", json=payload)
|
||||||
"/api/generate-agent", json={"instructions": instructions}
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
@@ -229,8 +238,7 @@ async def generate_agent_external(
|
|||||||
error_msg = data.get("error", "Unknown error from Agent Generator")
|
error_msg = data.get("error", "Unknown error from Agent Generator")
|
||||||
error_type = data.get("error_type", "unknown")
|
error_type = data.get("error_type", "unknown")
|
||||||
logger.error(
|
logger.error(
|
||||||
f"Agent Generator generation failed: {error_msg} "
|
f"Agent Generator generation failed: {error_msg} (type: {error_type})"
|
||||||
f"(type: {error_type})"
|
|
||||||
)
|
)
|
||||||
return _create_error_response(error_msg, error_type)
|
return _create_error_response(error_msg, error_type)
|
||||||
|
|
||||||
@@ -251,27 +259,31 @@ async def generate_agent_external(
|
|||||||
|
|
||||||
|
|
||||||
async def generate_agent_patch_external(
|
async def generate_agent_patch_external(
|
||||||
update_request: str, current_agent: dict[str, Any]
|
update_request: str,
|
||||||
|
current_agent: dict[str, Any],
|
||||||
|
library_agents: list[dict[str, Any]] | None = None,
|
||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
"""Call the external service to generate a patch for an existing agent.
|
"""Call the external service to generate a patch for an existing agent.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
update_request: Natural language description of changes
|
update_request: Natural language description of changes
|
||||||
current_agent: Current agent JSON
|
current_agent: Current agent JSON
|
||||||
|
library_agents: User's library agents available for sub-agent composition
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Updated agent JSON, clarifying questions dict, or error dict on error
|
Updated agent JSON, clarifying questions dict, or error dict on error
|
||||||
"""
|
"""
|
||||||
client = _get_client()
|
client = _get_client()
|
||||||
|
|
||||||
|
payload: dict[str, Any] = {
|
||||||
|
"update_request": update_request,
|
||||||
|
"current_agent_json": current_agent,
|
||||||
|
}
|
||||||
|
if library_agents:
|
||||||
|
payload["library_agents"] = library_agents
|
||||||
|
|
||||||
try:
|
try:
|
||||||
response = await client.post(
|
response = await client.post("/api/update-agent", json=payload)
|
||||||
"/api/update-agent",
|
|
||||||
json={
|
|
||||||
"update_request": update_request,
|
|
||||||
"current_agent_json": current_agent,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
data = response.json()
|
data = response.json()
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
"""Shared agent search functionality for find_agent and find_library_agent tools."""
|
"""Shared agent search functionality for find_agent and find_library_agent tools."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import Any, Literal
|
import re
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
from backend.api.features.library import db as library_db
|
from backend.api.features.library import db as library_db
|
||||||
from backend.api.features.store import db as store_db
|
from backend.api.features.store import db as store_db
|
||||||
from backend.data.graph import get_graph
|
|
||||||
from backend.util.exceptions import DatabaseError, NotFoundError
|
from backend.util.exceptions import DatabaseError, NotFoundError
|
||||||
|
|
||||||
from .models import (
|
from .models import (
|
||||||
@@ -15,36 +15,88 @@ from .models import (
|
|||||||
NoResultsResponse,
|
NoResultsResponse,
|
||||||
ToolResponseBase,
|
ToolResponseBase,
|
||||||
)
|
)
|
||||||
from .utils import fetch_graph_from_store_slug
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
SearchSource = Literal["marketplace", "library"]
|
SearchSource = Literal["marketplace", "library"]
|
||||||
|
|
||||||
|
_UUID_PATTERN = re.compile(
|
||||||
|
r"^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$",
|
||||||
|
re.IGNORECASE,
|
||||||
|
)
|
||||||
|
|
||||||
async def _fetch_input_schema_for_store_agent(
|
|
||||||
creator: str, slug: str
|
def _is_uuid(text: str) -> bool:
|
||||||
) -> dict[str, Any] | None:
|
"""Check if text is a valid UUID v4."""
|
||||||
"""Fetch input schema for a marketplace agent. Returns None on error."""
|
return bool(_UUID_PATTERN.match(text.strip()))
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_library_agent_by_id(user_id: str, agent_id: str) -> AgentInfo | None:
|
||||||
|
"""Fetch a library agent by ID (library agent ID or graph_id).
|
||||||
|
|
||||||
|
Tries multiple lookup strategies:
|
||||||
|
1. First by graph_id (AgentGraph primary key)
|
||||||
|
2. Then by library agent ID (LibraryAgent primary key)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: The user ID
|
||||||
|
agent_id: The ID to look up (can be graph_id or library agent ID)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
AgentInfo if found, None otherwise
|
||||||
|
"""
|
||||||
try:
|
try:
|
||||||
graph, _ = await fetch_graph_from_store_slug(creator, slug)
|
agent = await library_db.get_library_agent_by_graph_id(user_id, agent_id)
|
||||||
if graph and graph.input_schema:
|
if agent:
|
||||||
return graph.input_schema.get("properties", {})
|
logger.debug(f"Found library agent by graph_id: {agent.name}")
|
||||||
|
return AgentInfo(
|
||||||
|
id=agent.id,
|
||||||
|
name=agent.name,
|
||||||
|
description=agent.description or "",
|
||||||
|
source="library",
|
||||||
|
in_library=True,
|
||||||
|
creator=agent.creator_name,
|
||||||
|
status=agent.status.value,
|
||||||
|
can_access_graph=agent.can_access_graph,
|
||||||
|
has_external_trigger=agent.has_external_trigger,
|
||||||
|
new_output=agent.new_output,
|
||||||
|
graph_id=agent.graph_id,
|
||||||
|
)
|
||||||
|
except DatabaseError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not fetch input schema for {creator}/{slug}: {e}")
|
logger.warning(
|
||||||
return None
|
f"Could not fetch library agent by graph_id {agent_id}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_input_schema_for_library_agent(
|
|
||||||
graph_id: str, graph_version: int, user_id: str
|
|
||||||
) -> dict[str, Any] | None:
|
|
||||||
"""Fetch input schema for a library agent. Returns None on error."""
|
|
||||||
try:
|
try:
|
||||||
graph = await get_graph(graph_id, graph_version, user_id=user_id)
|
agent = await library_db.get_library_agent(agent_id, user_id)
|
||||||
if graph and graph.input_schema:
|
if agent:
|
||||||
return graph.input_schema.get("properties", {})
|
logger.debug(f"Found library agent by library_id: {agent.name}")
|
||||||
|
return AgentInfo(
|
||||||
|
id=agent.id,
|
||||||
|
name=agent.name,
|
||||||
|
description=agent.description or "",
|
||||||
|
source="library",
|
||||||
|
in_library=True,
|
||||||
|
creator=agent.creator_name,
|
||||||
|
status=agent.status.value,
|
||||||
|
can_access_graph=agent.can_access_graph,
|
||||||
|
has_external_trigger=agent.has_external_trigger,
|
||||||
|
new_output=agent.new_output,
|
||||||
|
graph_id=agent.graph_id,
|
||||||
|
)
|
||||||
|
except NotFoundError:
|
||||||
|
logger.debug(f"Library agent not found by library_id: {agent_id}")
|
||||||
|
except DatabaseError:
|
||||||
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug(f"Could not fetch input schema for graph {graph_id}: {e}")
|
logger.warning(
|
||||||
|
f"Could not fetch library agent by library_id {agent_id}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -83,10 +135,6 @@ async def search_agents(
|
|||||||
logger.info(f"Searching marketplace for: {query}")
|
logger.info(f"Searching marketplace for: {query}")
|
||||||
results = await store_db.get_store_agents(search_query=query, page_size=5)
|
results = await store_db.get_store_agents(search_query=query, page_size=5)
|
||||||
for agent in results.agents:
|
for agent in results.agents:
|
||||||
# Fetch input schema for this agent
|
|
||||||
inputs = await _fetch_input_schema_for_store_agent(
|
|
||||||
agent.creator, agent.slug
|
|
||||||
)
|
|
||||||
agents.append(
|
agents.append(
|
||||||
AgentInfo(
|
AgentInfo(
|
||||||
id=f"{agent.creator}/{agent.slug}",
|
id=f"{agent.creator}/{agent.slug}",
|
||||||
@@ -99,37 +147,39 @@ async def search_agents(
|
|||||||
rating=agent.rating,
|
rating=agent.rating,
|
||||||
runs=agent.runs,
|
runs=agent.runs,
|
||||||
is_featured=False,
|
is_featured=False,
|
||||||
inputs=inputs,
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else: # library
|
else:
|
||||||
logger.info(f"Searching user library for: {query}")
|
if _is_uuid(query):
|
||||||
results = await library_db.list_library_agents(
|
logger.info(f"Query looks like UUID, trying direct lookup: {query}")
|
||||||
user_id=user_id, # type: ignore[arg-type]
|
agent = await _get_library_agent_by_id(user_id, query) # type: ignore[arg-type]
|
||||||
search_term=query,
|
if agent:
|
||||||
page_size=10,
|
agents.append(agent)
|
||||||
)
|
logger.info(f"Found agent by direct ID lookup: {agent.name}")
|
||||||
for agent in results.agents:
|
|
||||||
# Fetch input schema for this agent
|
if not agents:
|
||||||
inputs = await _fetch_input_schema_for_library_agent(
|
logger.info(f"Searching user library for: {query}")
|
||||||
agent.graph_id, agent.graph_version, user_id # type: ignore[arg-type]
|
results = await library_db.list_library_agents(
|
||||||
|
user_id=user_id, # type: ignore[arg-type]
|
||||||
|
search_term=query,
|
||||||
|
page_size=10,
|
||||||
)
|
)
|
||||||
agents.append(
|
for agent in results.agents:
|
||||||
AgentInfo(
|
agents.append(
|
||||||
id=agent.id,
|
AgentInfo(
|
||||||
name=agent.name,
|
id=agent.id,
|
||||||
description=agent.description or "",
|
name=agent.name,
|
||||||
source="library",
|
description=agent.description or "",
|
||||||
in_library=True,
|
source="library",
|
||||||
creator=agent.creator_name,
|
in_library=True,
|
||||||
status=agent.status.value,
|
creator=agent.creator_name,
|
||||||
can_access_graph=agent.can_access_graph,
|
status=agent.status.value,
|
||||||
has_external_trigger=agent.has_external_trigger,
|
can_access_graph=agent.can_access_graph,
|
||||||
new_output=agent.new_output,
|
has_external_trigger=agent.has_external_trigger,
|
||||||
graph_id=agent.graph_id,
|
new_output=agent.new_output,
|
||||||
inputs=inputs,
|
graph_id=agent.graph_id,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
logger.info(f"Found {len(agents)} agents in {source}")
|
logger.info(f"Found {len(agents)} agents in {source}")
|
||||||
except NotFoundError:
|
except NotFoundError:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ from backend.api.features.chat.model import ChatSession
|
|||||||
from .agent_generator import (
|
from .agent_generator import (
|
||||||
AgentGeneratorNotConfiguredError,
|
AgentGeneratorNotConfiguredError,
|
||||||
decompose_goal,
|
decompose_goal,
|
||||||
|
enrich_library_agents_from_steps,
|
||||||
generate_agent,
|
generate_agent,
|
||||||
|
get_all_relevant_agents_for_generation,
|
||||||
get_user_message_for_error,
|
get_user_message_for_error,
|
||||||
save_agent_to_library,
|
save_agent_to_library,
|
||||||
)
|
)
|
||||||
@@ -103,9 +105,24 @@ class CreateAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 1: Decompose goal into steps
|
library_agents = None
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
library_agents = await get_all_relevant_agents_for_generation(
|
||||||
|
user_id=user_id,
|
||||||
|
search_query=description,
|
||||||
|
include_marketplace=True,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"Found {len(library_agents)} relevant agents for sub-agent composition"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch library agents: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
decomposition_result = await decompose_goal(description, context)
|
decomposition_result = await decompose_goal(
|
||||||
|
description, context, library_agents
|
||||||
|
)
|
||||||
except AgentGeneratorNotConfiguredError:
|
except AgentGeneratorNotConfiguredError:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message=(
|
message=(
|
||||||
@@ -124,7 +141,6 @@ class CreateAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if the result is an error from the external service
|
|
||||||
if decomposition_result.get("type") == "error":
|
if decomposition_result.get("type") == "error":
|
||||||
error_msg = decomposition_result.get("error", "Unknown error")
|
error_msg = decomposition_result.get("error", "Unknown error")
|
||||||
error_type = decomposition_result.get("error_type", "unknown")
|
error_type = decomposition_result.get("error_type", "unknown")
|
||||||
@@ -144,7 +160,6 @@ class CreateAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if LLM returned clarifying questions
|
|
||||||
if decomposition_result.get("type") == "clarifying_questions":
|
if decomposition_result.get("type") == "clarifying_questions":
|
||||||
questions = decomposition_result.get("questions", [])
|
questions = decomposition_result.get("questions", [])
|
||||||
return ClarificationNeededResponse(
|
return ClarificationNeededResponse(
|
||||||
@@ -163,7 +178,6 @@ class CreateAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check for unachievable/vague goals
|
|
||||||
if decomposition_result.get("type") == "unachievable_goal":
|
if decomposition_result.get("type") == "unachievable_goal":
|
||||||
suggested = decomposition_result.get("suggested_goal", "")
|
suggested = decomposition_result.get("suggested_goal", "")
|
||||||
reason = decomposition_result.get("reason", "")
|
reason = decomposition_result.get("reason", "")
|
||||||
@@ -190,9 +204,22 @@ class CreateAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 2: Generate agent JSON (external service handles fixing and validation)
|
if user_id and library_agents is not None:
|
||||||
|
try:
|
||||||
|
library_agents = await enrich_library_agents_from_steps(
|
||||||
|
user_id=user_id,
|
||||||
|
decomposition_result=decomposition_result,
|
||||||
|
existing_agents=library_agents,
|
||||||
|
include_marketplace=True,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"After enrichment: {len(library_agents)} total agents for sub-agent composition"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to enrich library agents from steps: {e}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
agent_json = await generate_agent(decomposition_result)
|
agent_json = await generate_agent(decomposition_result, library_agents)
|
||||||
except AgentGeneratorNotConfiguredError:
|
except AgentGeneratorNotConfiguredError:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message=(
|
message=(
|
||||||
@@ -211,7 +238,6 @@ class CreateAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if the result is an error from the external service
|
|
||||||
if isinstance(agent_json, dict) and agent_json.get("type") == "error":
|
if isinstance(agent_json, dict) and agent_json.get("type") == "error":
|
||||||
error_msg = agent_json.get("error", "Unknown error")
|
error_msg = agent_json.get("error", "Unknown error")
|
||||||
error_type = agent_json.get("error_type", "unknown")
|
error_type = agent_json.get("error_type", "unknown")
|
||||||
@@ -219,7 +245,12 @@ class CreateAgentTool(BaseTool):
|
|||||||
error_type,
|
error_type,
|
||||||
operation="generate the agent",
|
operation="generate the agent",
|
||||||
llm_parse_message="The AI had trouble generating the agent. Please try again or simplify your goal.",
|
llm_parse_message="The AI had trouble generating the agent. Please try again or simplify your goal.",
|
||||||
validation_message="The generated agent failed validation. Please try rephrasing your goal.",
|
validation_message=(
|
||||||
|
"I wasn't able to create a valid agent for this request. "
|
||||||
|
"The generated workflow had some structural issues. "
|
||||||
|
"Please try simplifying your goal or breaking it into smaller steps."
|
||||||
|
),
|
||||||
|
error_details=error_msg,
|
||||||
)
|
)
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message=user_message,
|
message=user_message,
|
||||||
@@ -237,7 +268,6 @@ class CreateAgentTool(BaseTool):
|
|||||||
node_count = len(agent_json.get("nodes", []))
|
node_count = len(agent_json.get("nodes", []))
|
||||||
link_count = len(agent_json.get("links", []))
|
link_count = len(agent_json.get("links", []))
|
||||||
|
|
||||||
# Step 3: Preview or save
|
|
||||||
if not save:
|
if not save:
|
||||||
return AgentPreviewResponse(
|
return AgentPreviewResponse(
|
||||||
message=(
|
message=(
|
||||||
@@ -252,7 +282,6 @@ class CreateAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save to library
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message="You must be logged in to save agents.",
|
message="You must be logged in to save agents.",
|
||||||
@@ -270,7 +299,7 @@ class CreateAgentTool(BaseTool):
|
|||||||
agent_id=created_graph.id,
|
agent_id=created_graph.id,
|
||||||
agent_name=created_graph.name,
|
agent_name=created_graph.name,
|
||||||
library_agent_id=library_agent.id,
|
library_agent_id=library_agent.id,
|
||||||
library_agent_link=f"/library/{library_agent.id}",
|
library_agent_link=f"/library/agents/{library_agent.id}",
|
||||||
agent_page_link=f"/build?flowID={created_graph.id}",
|
agent_page_link=f"/build?flowID={created_graph.id}",
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from .agent_generator import (
|
|||||||
AgentGeneratorNotConfiguredError,
|
AgentGeneratorNotConfiguredError,
|
||||||
generate_agent_patch,
|
generate_agent_patch,
|
||||||
get_agent_as_json,
|
get_agent_as_json,
|
||||||
|
get_all_relevant_agents_for_generation,
|
||||||
get_user_message_for_error,
|
get_user_message_for_error,
|
||||||
save_agent_to_library,
|
save_agent_to_library,
|
||||||
)
|
)
|
||||||
@@ -117,7 +118,6 @@ class EditAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Step 1: Fetch current agent
|
|
||||||
current_agent = await get_agent_as_json(agent_id, user_id)
|
current_agent = await get_agent_as_json(agent_id, user_id)
|
||||||
|
|
||||||
if current_agent is None:
|
if current_agent is None:
|
||||||
@@ -127,14 +127,30 @@ class EditAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build the update request with context
|
library_agents = None
|
||||||
|
if user_id:
|
||||||
|
try:
|
||||||
|
graph_id = current_agent.get("id")
|
||||||
|
library_agents = await get_all_relevant_agents_for_generation(
|
||||||
|
user_id=user_id,
|
||||||
|
search_query=changes,
|
||||||
|
exclude_graph_id=graph_id,
|
||||||
|
include_marketplace=True,
|
||||||
|
)
|
||||||
|
logger.debug(
|
||||||
|
f"Found {len(library_agents)} relevant agents for sub-agent composition"
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to fetch library agents: {e}")
|
||||||
|
|
||||||
update_request = changes
|
update_request = changes
|
||||||
if context:
|
if context:
|
||||||
update_request = f"{changes}\n\nAdditional context:\n{context}"
|
update_request = f"{changes}\n\nAdditional context:\n{context}"
|
||||||
|
|
||||||
# Step 2: Generate updated agent (external service handles fixing and validation)
|
|
||||||
try:
|
try:
|
||||||
result = await generate_agent_patch(update_request, current_agent)
|
result = await generate_agent_patch(
|
||||||
|
update_request, current_agent, library_agents
|
||||||
|
)
|
||||||
except AgentGeneratorNotConfiguredError:
|
except AgentGeneratorNotConfiguredError:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message=(
|
message=(
|
||||||
@@ -153,7 +169,6 @@ class EditAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if the result is an error from the external service
|
|
||||||
if isinstance(result, dict) and result.get("type") == "error":
|
if isinstance(result, dict) and result.get("type") == "error":
|
||||||
error_msg = result.get("error", "Unknown error")
|
error_msg = result.get("error", "Unknown error")
|
||||||
error_type = result.get("error_type", "unknown")
|
error_type = result.get("error_type", "unknown")
|
||||||
@@ -162,6 +177,7 @@ class EditAgentTool(BaseTool):
|
|||||||
operation="generate the changes",
|
operation="generate the changes",
|
||||||
llm_parse_message="The AI had trouble generating the changes. Please try again or simplify your request.",
|
llm_parse_message="The AI had trouble generating the changes. Please try again or simplify your request.",
|
||||||
validation_message="The generated changes failed validation. Please try rephrasing your request.",
|
validation_message="The generated changes failed validation. Please try rephrasing your request.",
|
||||||
|
error_details=error_msg,
|
||||||
)
|
)
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message=user_message,
|
message=user_message,
|
||||||
@@ -175,7 +191,6 @@ class EditAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Check if LLM returned clarifying questions
|
|
||||||
if result.get("type") == "clarifying_questions":
|
if result.get("type") == "clarifying_questions":
|
||||||
questions = result.get("questions", [])
|
questions = result.get("questions", [])
|
||||||
return ClarificationNeededResponse(
|
return ClarificationNeededResponse(
|
||||||
@@ -194,7 +209,6 @@ class EditAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Result is the updated agent JSON
|
|
||||||
updated_agent = result
|
updated_agent = result
|
||||||
|
|
||||||
agent_name = updated_agent.get("name", "Updated Agent")
|
agent_name = updated_agent.get("name", "Updated Agent")
|
||||||
@@ -202,7 +216,6 @@ class EditAgentTool(BaseTool):
|
|||||||
node_count = len(updated_agent.get("nodes", []))
|
node_count = len(updated_agent.get("nodes", []))
|
||||||
link_count = len(updated_agent.get("links", []))
|
link_count = len(updated_agent.get("links", []))
|
||||||
|
|
||||||
# Step 3: Preview or save
|
|
||||||
if not save:
|
if not save:
|
||||||
return AgentPreviewResponse(
|
return AgentPreviewResponse(
|
||||||
message=(
|
message=(
|
||||||
@@ -218,7 +231,6 @@ class EditAgentTool(BaseTool):
|
|||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Save to library (creates a new version)
|
|
||||||
if not user_id:
|
if not user_id:
|
||||||
return ErrorResponse(
|
return ErrorResponse(
|
||||||
message="You must be logged in to save agents.",
|
message="You must be logged in to save agents.",
|
||||||
@@ -236,7 +248,7 @@ class EditAgentTool(BaseTool):
|
|||||||
agent_id=created_graph.id,
|
agent_id=created_graph.id,
|
||||||
agent_name=created_graph.name,
|
agent_name=created_graph.name,
|
||||||
library_agent_id=library_agent.id,
|
library_agent_id=library_agent.id,
|
||||||
library_agent_link=f"/library/{library_agent.id}",
|
library_agent_link=f"/library/agents/{library_agent.id}",
|
||||||
agent_page_link=f"/build?flowID={created_graph.id}",
|
agent_page_link=f"/build?flowID={created_graph.id}",
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -68,10 +68,6 @@ class AgentInfo(BaseModel):
|
|||||||
has_external_trigger: bool | None = None
|
has_external_trigger: bool | None = None
|
||||||
new_output: bool | None = None
|
new_output: bool | None = None
|
||||||
graph_id: str | None = None
|
graph_id: str | None = None
|
||||||
inputs: dict[str, Any] | None = Field(
|
|
||||||
default=None,
|
|
||||||
description="Input schema for the agent (properties from input_schema)",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class AgentsFoundResponse(ToolResponseBase):
|
class AgentsFoundResponse(ToolResponseBase):
|
||||||
|
|||||||
@@ -273,27 +273,6 @@ class RunAgentTool(BaseTool):
|
|||||||
input_properties = graph.input_schema.get("properties", {})
|
input_properties = graph.input_schema.get("properties", {})
|
||||||
required_fields = set(graph.input_schema.get("required", []))
|
required_fields = set(graph.input_schema.get("required", []))
|
||||||
provided_inputs = set(params.inputs.keys())
|
provided_inputs = set(params.inputs.keys())
|
||||||
valid_fields = set(input_properties.keys())
|
|
||||||
|
|
||||||
# Check for unknown fields - reject early with helpful message
|
|
||||||
unknown_fields = provided_inputs - valid_fields
|
|
||||||
if unknown_fields:
|
|
||||||
valid_list = ", ".join(sorted(valid_fields)) if valid_fields else "none"
|
|
||||||
return AgentDetailsResponse(
|
|
||||||
message=(
|
|
||||||
f"Unknown input field(s) provided: {', '.join(sorted(unknown_fields))}. "
|
|
||||||
f"Valid fields for '{graph.name}': {valid_list}. "
|
|
||||||
"Please check the field names and try again."
|
|
||||||
),
|
|
||||||
session_id=session_id,
|
|
||||||
agent=self._build_agent_details(
|
|
||||||
graph,
|
|
||||||
extract_credentials_from_schema(graph.credentials_input_schema),
|
|
||||||
),
|
|
||||||
user_authenticated=True,
|
|
||||||
graph_id=graph.id,
|
|
||||||
graph_version=graph.version,
|
|
||||||
)
|
|
||||||
|
|
||||||
# If agent has inputs but none were provided AND use_defaults is not set,
|
# If agent has inputs but none were provided AND use_defaults is not set,
|
||||||
# always show what's available first so user can decide
|
# always show what's available first so user can decide
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ async def list_library_agents(
|
|||||||
sort_by: library_model.LibraryAgentSort = library_model.LibraryAgentSort.UPDATED_AT,
|
sort_by: library_model.LibraryAgentSort = library_model.LibraryAgentSort.UPDATED_AT,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 50,
|
page_size: int = 50,
|
||||||
|
include_executions: bool = False,
|
||||||
) -> library_model.LibraryAgentResponse:
|
) -> library_model.LibraryAgentResponse:
|
||||||
"""
|
"""
|
||||||
Retrieves a paginated list of LibraryAgent records for a given user.
|
Retrieves a paginated list of LibraryAgent records for a given user.
|
||||||
@@ -49,6 +50,9 @@ async def list_library_agents(
|
|||||||
sort_by: Sorting field (createdAt, updatedAt, isFavorite, isCreatedByUser).
|
sort_by: Sorting field (createdAt, updatedAt, isFavorite, isCreatedByUser).
|
||||||
page: Current page (1-indexed).
|
page: Current page (1-indexed).
|
||||||
page_size: Number of items per page.
|
page_size: Number of items per page.
|
||||||
|
include_executions: Whether to include execution data for status calculation.
|
||||||
|
Defaults to False for performance (UI fetches status separately).
|
||||||
|
Set to True when accurate status/metrics are needed (e.g., agent generator).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A LibraryAgentResponse containing the list of agents and pagination details.
|
A LibraryAgentResponse containing the list of agents and pagination details.
|
||||||
@@ -76,7 +80,6 @@ async def list_library_agents(
|
|||||||
"isArchived": False,
|
"isArchived": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Build search filter if applicable
|
|
||||||
if search_term:
|
if search_term:
|
||||||
where_clause["OR"] = [
|
where_clause["OR"] = [
|
||||||
{
|
{
|
||||||
@@ -93,7 +96,6 @@ async def list_library_agents(
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
# Determine sorting
|
|
||||||
order_by: prisma.types.LibraryAgentOrderByInput | None = None
|
order_by: prisma.types.LibraryAgentOrderByInput | None = None
|
||||||
|
|
||||||
if sort_by == library_model.LibraryAgentSort.CREATED_AT:
|
if sort_by == library_model.LibraryAgentSort.CREATED_AT:
|
||||||
@@ -105,7 +107,7 @@ async def list_library_agents(
|
|||||||
library_agents = await prisma.models.LibraryAgent.prisma().find_many(
|
library_agents = await prisma.models.LibraryAgent.prisma().find_many(
|
||||||
where=where_clause,
|
where=where_clause,
|
||||||
include=library_agent_include(
|
include=library_agent_include(
|
||||||
user_id, include_nodes=False, include_executions=False
|
user_id, include_nodes=False, include_executions=include_executions
|
||||||
),
|
),
|
||||||
order=order_by,
|
order=order_by,
|
||||||
skip=(page - 1) * page_size,
|
skip=(page - 1) * page_size,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import pydantic
|
|||||||
from backend.data.block import BlockInput
|
from backend.data.block import BlockInput
|
||||||
from backend.data.graph import GraphModel, GraphSettings, GraphTriggerInfo
|
from backend.data.graph import GraphModel, GraphSettings, GraphTriggerInfo
|
||||||
from backend.data.model import CredentialsMetaInput, is_credentials_field_name
|
from backend.data.model import CredentialsMetaInput, is_credentials_field_name
|
||||||
|
from backend.util.json import loads as json_loads
|
||||||
from backend.util.models import Pagination
|
from backend.util.models import Pagination
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -16,10 +17,10 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
|
|
||||||
class LibraryAgentStatus(str, Enum):
|
class LibraryAgentStatus(str, Enum):
|
||||||
COMPLETED = "COMPLETED" # All runs completed
|
COMPLETED = "COMPLETED"
|
||||||
HEALTHY = "HEALTHY" # Agent is running (not all runs have completed)
|
HEALTHY = "HEALTHY"
|
||||||
WAITING = "WAITING" # Agent is queued or waiting to start
|
WAITING = "WAITING"
|
||||||
ERROR = "ERROR" # Agent is in an error state
|
ERROR = "ERROR"
|
||||||
|
|
||||||
|
|
||||||
class MarketplaceListingCreator(pydantic.BaseModel):
|
class MarketplaceListingCreator(pydantic.BaseModel):
|
||||||
@@ -39,6 +40,30 @@ class MarketplaceListing(pydantic.BaseModel):
|
|||||||
creator: MarketplaceListingCreator
|
creator: MarketplaceListingCreator
|
||||||
|
|
||||||
|
|
||||||
|
class RecentExecution(pydantic.BaseModel):
|
||||||
|
"""Summary of a recent execution for quality assessment.
|
||||||
|
|
||||||
|
Used by the LLM to understand the agent's recent performance with specific examples
|
||||||
|
rather than just aggregate statistics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
status: str
|
||||||
|
correctness_score: float | None = None
|
||||||
|
activity_summary: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_settings(settings: dict | str | None) -> GraphSettings:
|
||||||
|
"""Parse settings from database, handling both dict and string formats."""
|
||||||
|
if settings is None:
|
||||||
|
return GraphSettings()
|
||||||
|
try:
|
||||||
|
if isinstance(settings, str):
|
||||||
|
settings = json_loads(settings)
|
||||||
|
return GraphSettings.model_validate(settings)
|
||||||
|
except Exception:
|
||||||
|
return GraphSettings()
|
||||||
|
|
||||||
|
|
||||||
class LibraryAgent(pydantic.BaseModel):
|
class LibraryAgent(pydantic.BaseModel):
|
||||||
"""
|
"""
|
||||||
Represents an agent in the library, including metadata for display and
|
Represents an agent in the library, including metadata for display and
|
||||||
@@ -48,7 +73,7 @@ class LibraryAgent(pydantic.BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
graph_id: str
|
graph_id: str
|
||||||
graph_version: int
|
graph_version: int
|
||||||
owner_user_id: str # ID of user who owns/created this agent graph
|
owner_user_id: str
|
||||||
|
|
||||||
image_url: str | None
|
image_url: str | None
|
||||||
|
|
||||||
@@ -64,7 +89,7 @@ class LibraryAgent(pydantic.BaseModel):
|
|||||||
description: str
|
description: str
|
||||||
instructions: str | None = None
|
instructions: str | None = None
|
||||||
|
|
||||||
input_schema: dict[str, Any] # Should be BlockIOObjectSubSchema in frontend
|
input_schema: dict[str, Any]
|
||||||
output_schema: dict[str, Any]
|
output_schema: dict[str, Any]
|
||||||
credentials_input_schema: dict[str, Any] | None = pydantic.Field(
|
credentials_input_schema: dict[str, Any] | None = pydantic.Field(
|
||||||
description="Input schema for credentials required by the agent",
|
description="Input schema for credentials required by the agent",
|
||||||
@@ -81,25 +106,19 @@ class LibraryAgent(pydantic.BaseModel):
|
|||||||
)
|
)
|
||||||
trigger_setup_info: Optional[GraphTriggerInfo] = None
|
trigger_setup_info: Optional[GraphTriggerInfo] = None
|
||||||
|
|
||||||
# Indicates whether there's a new output (based on recent runs)
|
|
||||||
new_output: bool
|
new_output: bool
|
||||||
|
execution_count: int = 0
|
||||||
# Whether the user can access the underlying graph
|
success_rate: float | None = None
|
||||||
|
avg_correctness_score: float | None = None
|
||||||
|
recent_executions: list[RecentExecution] = pydantic.Field(
|
||||||
|
default_factory=list,
|
||||||
|
description="List of recent executions with status, score, and summary",
|
||||||
|
)
|
||||||
can_access_graph: bool
|
can_access_graph: bool
|
||||||
|
|
||||||
# Indicates if this agent is the latest version
|
|
||||||
is_latest_version: bool
|
is_latest_version: bool
|
||||||
|
|
||||||
# Whether the agent is marked as favorite by the user
|
|
||||||
is_favorite: bool
|
is_favorite: bool
|
||||||
|
|
||||||
# Recommended schedule cron (from marketplace agents)
|
|
||||||
recommended_schedule_cron: str | None = None
|
recommended_schedule_cron: str | None = None
|
||||||
|
|
||||||
# User-specific settings for this library agent
|
|
||||||
settings: GraphSettings = pydantic.Field(default_factory=GraphSettings)
|
settings: GraphSettings = pydantic.Field(default_factory=GraphSettings)
|
||||||
|
|
||||||
# Marketplace listing information if the agent has been published
|
|
||||||
marketplace_listing: Optional["MarketplaceListing"] = None
|
marketplace_listing: Optional["MarketplaceListing"] = None
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -123,7 +142,6 @@ class LibraryAgent(pydantic.BaseModel):
|
|||||||
agent_updated_at = agent.AgentGraph.updatedAt
|
agent_updated_at = agent.AgentGraph.updatedAt
|
||||||
lib_agent_updated_at = agent.updatedAt
|
lib_agent_updated_at = agent.updatedAt
|
||||||
|
|
||||||
# Compute updated_at as the latest between library agent and graph
|
|
||||||
updated_at = (
|
updated_at = (
|
||||||
max(agent_updated_at, lib_agent_updated_at)
|
max(agent_updated_at, lib_agent_updated_at)
|
||||||
if agent_updated_at
|
if agent_updated_at
|
||||||
@@ -136,7 +154,6 @@ class LibraryAgent(pydantic.BaseModel):
|
|||||||
creator_name = agent.Creator.name or "Unknown"
|
creator_name = agent.Creator.name or "Unknown"
|
||||||
creator_image_url = agent.Creator.avatarUrl or ""
|
creator_image_url = agent.Creator.avatarUrl or ""
|
||||||
|
|
||||||
# Logic to calculate status and new_output
|
|
||||||
week_ago = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
|
week_ago = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(
|
||||||
days=7
|
days=7
|
||||||
)
|
)
|
||||||
@@ -145,13 +162,55 @@ class LibraryAgent(pydantic.BaseModel):
|
|||||||
status = status_result.status
|
status = status_result.status
|
||||||
new_output = status_result.new_output
|
new_output = status_result.new_output
|
||||||
|
|
||||||
# Check if user can access the graph
|
execution_count = len(executions)
|
||||||
can_access_graph = agent.AgentGraph.userId == agent.userId
|
success_rate: float | None = None
|
||||||
|
avg_correctness_score: float | None = None
|
||||||
|
if execution_count > 0:
|
||||||
|
success_count = sum(
|
||||||
|
1
|
||||||
|
for e in executions
|
||||||
|
if e.executionStatus == prisma.enums.AgentExecutionStatus.COMPLETED
|
||||||
|
)
|
||||||
|
success_rate = (success_count / execution_count) * 100
|
||||||
|
|
||||||
# Hard-coded to True until a method to check is implemented
|
correctness_scores = []
|
||||||
|
for e in executions:
|
||||||
|
if e.stats and isinstance(e.stats, dict):
|
||||||
|
score = e.stats.get("correctness_score")
|
||||||
|
if score is not None and isinstance(score, (int, float)):
|
||||||
|
correctness_scores.append(float(score))
|
||||||
|
if correctness_scores:
|
||||||
|
avg_correctness_score = sum(correctness_scores) / len(
|
||||||
|
correctness_scores
|
||||||
|
)
|
||||||
|
|
||||||
|
recent_executions: list[RecentExecution] = []
|
||||||
|
for e in executions:
|
||||||
|
exec_score: float | None = None
|
||||||
|
exec_summary: str | None = None
|
||||||
|
if e.stats and isinstance(e.stats, dict):
|
||||||
|
score = e.stats.get("correctness_score")
|
||||||
|
if score is not None and isinstance(score, (int, float)):
|
||||||
|
exec_score = float(score)
|
||||||
|
summary = e.stats.get("activity_status")
|
||||||
|
if summary is not None and isinstance(summary, str):
|
||||||
|
exec_summary = summary
|
||||||
|
exec_status = (
|
||||||
|
e.executionStatus.value
|
||||||
|
if hasattr(e.executionStatus, "value")
|
||||||
|
else str(e.executionStatus)
|
||||||
|
)
|
||||||
|
recent_executions.append(
|
||||||
|
RecentExecution(
|
||||||
|
status=exec_status,
|
||||||
|
correctness_score=exec_score,
|
||||||
|
activity_summary=exec_summary,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
can_access_graph = agent.AgentGraph.userId == agent.userId
|
||||||
is_latest_version = True
|
is_latest_version = True
|
||||||
|
|
||||||
# Build marketplace_listing if available
|
|
||||||
marketplace_listing_data = None
|
marketplace_listing_data = None
|
||||||
if store_listing and store_listing.ActiveVersion and profile:
|
if store_listing and store_listing.ActiveVersion and profile:
|
||||||
creator_data = MarketplaceListingCreator(
|
creator_data = MarketplaceListingCreator(
|
||||||
@@ -190,11 +249,15 @@ class LibraryAgent(pydantic.BaseModel):
|
|||||||
has_sensitive_action=graph.has_sensitive_action,
|
has_sensitive_action=graph.has_sensitive_action,
|
||||||
trigger_setup_info=graph.trigger_setup_info,
|
trigger_setup_info=graph.trigger_setup_info,
|
||||||
new_output=new_output,
|
new_output=new_output,
|
||||||
|
execution_count=execution_count,
|
||||||
|
success_rate=success_rate,
|
||||||
|
avg_correctness_score=avg_correctness_score,
|
||||||
|
recent_executions=recent_executions,
|
||||||
can_access_graph=can_access_graph,
|
can_access_graph=can_access_graph,
|
||||||
is_latest_version=is_latest_version,
|
is_latest_version=is_latest_version,
|
||||||
is_favorite=agent.isFavorite,
|
is_favorite=agent.isFavorite,
|
||||||
recommended_schedule_cron=agent.AgentGraph.recommendedScheduleCron,
|
recommended_schedule_cron=agent.AgentGraph.recommendedScheduleCron,
|
||||||
settings=GraphSettings.model_validate(agent.settings),
|
settings=_parse_settings(agent.settings),
|
||||||
marketplace_listing=marketplace_listing_data,
|
marketplace_listing=marketplace_listing_data,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -220,18 +283,15 @@ def _calculate_agent_status(
|
|||||||
if not executions:
|
if not executions:
|
||||||
return AgentStatusResult(status=LibraryAgentStatus.COMPLETED, new_output=False)
|
return AgentStatusResult(status=LibraryAgentStatus.COMPLETED, new_output=False)
|
||||||
|
|
||||||
# Track how many times each execution status appears
|
|
||||||
status_counts = {status: 0 for status in prisma.enums.AgentExecutionStatus}
|
status_counts = {status: 0 for status in prisma.enums.AgentExecutionStatus}
|
||||||
new_output = False
|
new_output = False
|
||||||
|
|
||||||
for execution in executions:
|
for execution in executions:
|
||||||
# Check if there's a completed run more recent than `recent_threshold`
|
|
||||||
if execution.createdAt >= recent_threshold:
|
if execution.createdAt >= recent_threshold:
|
||||||
if execution.executionStatus == prisma.enums.AgentExecutionStatus.COMPLETED:
|
if execution.executionStatus == prisma.enums.AgentExecutionStatus.COMPLETED:
|
||||||
new_output = True
|
new_output = True
|
||||||
status_counts[execution.executionStatus] += 1
|
status_counts[execution.executionStatus] += 1
|
||||||
|
|
||||||
# Determine the final status based on counts
|
|
||||||
if status_counts[prisma.enums.AgentExecutionStatus.FAILED] > 0:
|
if status_counts[prisma.enums.AgentExecutionStatus.FAILED] > 0:
|
||||||
return AgentStatusResult(status=LibraryAgentStatus.ERROR, new_output=new_output)
|
return AgentStatusResult(status=LibraryAgentStatus.ERROR, new_output=new_output)
|
||||||
elif status_counts[prisma.enums.AgentExecutionStatus.QUEUED] > 0:
|
elif status_counts[prisma.enums.AgentExecutionStatus.QUEUED] > 0:
|
||||||
|
|||||||
@@ -31,6 +31,10 @@
|
|||||||
"has_sensitive_action": false,
|
"has_sensitive_action": false,
|
||||||
"trigger_setup_info": null,
|
"trigger_setup_info": null,
|
||||||
"new_output": false,
|
"new_output": false,
|
||||||
|
"execution_count": 0,
|
||||||
|
"success_rate": null,
|
||||||
|
"avg_correctness_score": null,
|
||||||
|
"recent_executions": [],
|
||||||
"can_access_graph": true,
|
"can_access_graph": true,
|
||||||
"is_latest_version": true,
|
"is_latest_version": true,
|
||||||
"is_favorite": false,
|
"is_favorite": false,
|
||||||
@@ -72,6 +76,10 @@
|
|||||||
"has_sensitive_action": false,
|
"has_sensitive_action": false,
|
||||||
"trigger_setup_info": null,
|
"trigger_setup_info": null,
|
||||||
"new_output": false,
|
"new_output": false,
|
||||||
|
"execution_count": 0,
|
||||||
|
"success_rate": null,
|
||||||
|
"avg_correctness_score": null,
|
||||||
|
"recent_executions": [],
|
||||||
"can_access_graph": false,
|
"can_access_graph": false,
|
||||||
"is_latest_version": true,
|
"is_latest_version": true,
|
||||||
"is_favorite": false,
|
"is_favorite": false,
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ class TestDecomposeGoal:
|
|||||||
|
|
||||||
result = await core.decompose_goal("Build a chatbot")
|
result = await core.decompose_goal("Build a chatbot")
|
||||||
|
|
||||||
mock_external.assert_called_once_with("Build a chatbot", "")
|
# library_agents defaults to None
|
||||||
|
mock_external.assert_called_once_with("Build a chatbot", "", None)
|
||||||
assert result == expected_result
|
assert result == expected_result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -74,7 +75,8 @@ class TestDecomposeGoal:
|
|||||||
|
|
||||||
await core.decompose_goal("Build a chatbot", "Use Python")
|
await core.decompose_goal("Build a chatbot", "Use Python")
|
||||||
|
|
||||||
mock_external.assert_called_once_with("Build a chatbot", "Use Python")
|
# library_agents defaults to None
|
||||||
|
mock_external.assert_called_once_with("Build a chatbot", "Use Python", None)
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_returns_none_on_service_failure(self):
|
async def test_returns_none_on_service_failure(self):
|
||||||
@@ -109,7 +111,8 @@ class TestGenerateAgent:
|
|||||||
instructions = {"type": "instructions", "steps": ["Step 1"]}
|
instructions = {"type": "instructions", "steps": ["Step 1"]}
|
||||||
result = await core.generate_agent(instructions)
|
result = await core.generate_agent(instructions)
|
||||||
|
|
||||||
mock_external.assert_called_once_with(instructions)
|
# library_agents defaults to None
|
||||||
|
mock_external.assert_called_once_with(instructions, None)
|
||||||
# Result should have id, version, is_active added if not present
|
# Result should have id, version, is_active added if not present
|
||||||
assert result is not None
|
assert result is not None
|
||||||
assert result["name"] == "Test Agent"
|
assert result["name"] == "Test Agent"
|
||||||
@@ -174,7 +177,8 @@ class TestGenerateAgentPatch:
|
|||||||
current_agent = {"nodes": [], "links": []}
|
current_agent = {"nodes": [], "links": []}
|
||||||
result = await core.generate_agent_patch("Add a node", current_agent)
|
result = await core.generate_agent_patch("Add a node", current_agent)
|
||||||
|
|
||||||
mock_external.assert_called_once_with("Add a node", current_agent)
|
# library_agents defaults to None
|
||||||
|
mock_external.assert_called_once_with("Add a node", current_agent, None)
|
||||||
assert result == expected_result
|
assert result == expected_result
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -0,0 +1,841 @@
|
|||||||
|
"""
|
||||||
|
Tests for library agent fetching functionality in agent generator.
|
||||||
|
|
||||||
|
This test suite verifies the search-based library agent fetching,
|
||||||
|
including the combination of library and marketplace agents.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from backend.api.features.chat.tools.agent_generator import core
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLibraryAgentsForGeneration:
|
||||||
|
"""Test get_library_agents_for_generation function."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetches_agents_with_search_term(self):
|
||||||
|
"""Test that search_term is passed to the library db."""
|
||||||
|
# Create a mock agent with proper attribute values
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_agent.graph_id = "agent-123"
|
||||||
|
mock_agent.graph_version = 1
|
||||||
|
mock_agent.name = "Email Agent"
|
||||||
|
mock_agent.description = "Sends emails"
|
||||||
|
mock_agent.input_schema = {"properties": {}}
|
||||||
|
mock_agent.output_schema = {"properties": {}}
|
||||||
|
mock_agent.recent_executions = []
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.agents = [mock_agent]
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
core.library_db,
|
||||||
|
"list_library_agents",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_response,
|
||||||
|
) as mock_list:
|
||||||
|
result = await core.get_library_agents_for_generation(
|
||||||
|
user_id="user-123",
|
||||||
|
search_query="send email",
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_list.assert_called_once_with(
|
||||||
|
user_id="user-123",
|
||||||
|
search_term="send email",
|
||||||
|
page=1,
|
||||||
|
page_size=15,
|
||||||
|
include_executions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify result format
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["graph_id"] == "agent-123"
|
||||||
|
assert result[0]["name"] == "Email Agent"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_excludes_specified_graph_id(self):
|
||||||
|
"""Test that agents with excluded graph_id are filtered out."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.agents = [
|
||||||
|
MagicMock(
|
||||||
|
graph_id="agent-123",
|
||||||
|
graph_version=1,
|
||||||
|
name="Agent 1",
|
||||||
|
description="First agent",
|
||||||
|
input_schema={},
|
||||||
|
output_schema={},
|
||||||
|
recent_executions=[],
|
||||||
|
),
|
||||||
|
MagicMock(
|
||||||
|
graph_id="agent-456",
|
||||||
|
graph_version=1,
|
||||||
|
name="Agent 2",
|
||||||
|
description="Second agent",
|
||||||
|
input_schema={},
|
||||||
|
output_schema={},
|
||||||
|
recent_executions=[],
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
core.library_db,
|
||||||
|
"list_library_agents",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_response,
|
||||||
|
):
|
||||||
|
result = await core.get_library_agents_for_generation(
|
||||||
|
user_id="user-123",
|
||||||
|
exclude_graph_id="agent-123",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify the excluded agent is not in results
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["graph_id"] == "agent-456"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_respects_max_results(self):
|
||||||
|
"""Test that max_results parameter limits the page_size."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.agents = []
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
core.library_db,
|
||||||
|
"list_library_agents",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_response,
|
||||||
|
) as mock_list:
|
||||||
|
await core.get_library_agents_for_generation(
|
||||||
|
user_id="user-123",
|
||||||
|
max_results=5,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_list.assert_called_once_with(
|
||||||
|
user_id="user-123",
|
||||||
|
search_term=None,
|
||||||
|
page=1,
|
||||||
|
page_size=5,
|
||||||
|
include_executions=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestSearchMarketplaceAgentsForGeneration:
|
||||||
|
"""Test search_marketplace_agents_for_generation function."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_searches_marketplace_with_query(self):
|
||||||
|
"""Test that marketplace is searched with the query."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.agents = [
|
||||||
|
MagicMock(
|
||||||
|
agent_name="Public Agent",
|
||||||
|
description="A public agent",
|
||||||
|
sub_heading="Does something useful",
|
||||||
|
creator="creator-1",
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
# The store_db is dynamically imported, so patch the import path
|
||||||
|
with patch(
|
||||||
|
"backend.api.features.store.db.get_store_agents",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_response,
|
||||||
|
) as mock_search:
|
||||||
|
result = await core.search_marketplace_agents_for_generation(
|
||||||
|
search_query="automation",
|
||||||
|
max_results=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_search.assert_called_once_with(
|
||||||
|
search_query="automation",
|
||||||
|
page=1,
|
||||||
|
page_size=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["name"] == "Public Agent"
|
||||||
|
assert result[0]["is_marketplace_agent"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_handles_marketplace_error_gracefully(self):
|
||||||
|
"""Test that marketplace errors don't crash the function."""
|
||||||
|
with patch(
|
||||||
|
"backend.api.features.store.db.get_store_agents",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=Exception("Marketplace unavailable"),
|
||||||
|
):
|
||||||
|
result = await core.search_marketplace_agents_for_generation(
|
||||||
|
search_query="test"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return empty list, not raise exception
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetAllRelevantAgentsForGeneration:
|
||||||
|
"""Test get_all_relevant_agents_for_generation function."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_combines_library_and_marketplace_agents(self):
|
||||||
|
"""Test that agents from both sources are combined."""
|
||||||
|
library_agents = [
|
||||||
|
{
|
||||||
|
"graph_id": "lib-123",
|
||||||
|
"graph_version": 1,
|
||||||
|
"name": "Library Agent",
|
||||||
|
"description": "From library",
|
||||||
|
"input_schema": {},
|
||||||
|
"output_schema": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
marketplace_agents = [
|
||||||
|
{
|
||||||
|
"name": "Market Agent",
|
||||||
|
"description": "From marketplace",
|
||||||
|
"sub_heading": "Sub heading",
|
||||||
|
"creator": "creator-1",
|
||||||
|
"is_marketplace_agent": True,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
core,
|
||||||
|
"get_library_agents_for_generation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=library_agents,
|
||||||
|
):
|
||||||
|
with patch.object(
|
||||||
|
core,
|
||||||
|
"search_marketplace_agents_for_generation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=marketplace_agents,
|
||||||
|
):
|
||||||
|
result = await core.get_all_relevant_agents_for_generation(
|
||||||
|
user_id="user-123",
|
||||||
|
search_query="test query",
|
||||||
|
include_marketplace=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Library agents should come first
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["name"] == "Library Agent"
|
||||||
|
assert result[1]["name"] == "Market Agent"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deduplicates_by_name(self):
|
||||||
|
"""Test that marketplace agents with same name as library are excluded."""
|
||||||
|
library_agents = [
|
||||||
|
{
|
||||||
|
"graph_id": "lib-123",
|
||||||
|
"graph_version": 1,
|
||||||
|
"name": "Shared Agent",
|
||||||
|
"description": "From library",
|
||||||
|
"input_schema": {},
|
||||||
|
"output_schema": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
marketplace_agents = [
|
||||||
|
{
|
||||||
|
"name": "Shared Agent", # Same name, should be deduplicated
|
||||||
|
"description": "From marketplace",
|
||||||
|
"sub_heading": "Sub heading",
|
||||||
|
"creator": "creator-1",
|
||||||
|
"is_marketplace_agent": True,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Unique Agent",
|
||||||
|
"description": "Only in marketplace",
|
||||||
|
"sub_heading": "Sub heading",
|
||||||
|
"creator": "creator-2",
|
||||||
|
"is_marketplace_agent": True,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
core,
|
||||||
|
"get_library_agents_for_generation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=library_agents,
|
||||||
|
):
|
||||||
|
with patch.object(
|
||||||
|
core,
|
||||||
|
"search_marketplace_agents_for_generation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=marketplace_agents,
|
||||||
|
):
|
||||||
|
result = await core.get_all_relevant_agents_for_generation(
|
||||||
|
user_id="user-123",
|
||||||
|
search_query="test",
|
||||||
|
include_marketplace=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Shared Agent from marketplace should be excluded
|
||||||
|
assert len(result) == 2
|
||||||
|
names = [a["name"] for a in result]
|
||||||
|
assert "Shared Agent" in names
|
||||||
|
assert "Unique Agent" in names
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skips_marketplace_when_disabled(self):
|
||||||
|
"""Test that marketplace is not searched when include_marketplace=False."""
|
||||||
|
library_agents = [
|
||||||
|
{
|
||||||
|
"graph_id": "lib-123",
|
||||||
|
"graph_version": 1,
|
||||||
|
"name": "Library Agent",
|
||||||
|
"description": "From library",
|
||||||
|
"input_schema": {},
|
||||||
|
"output_schema": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
core,
|
||||||
|
"get_library_agents_for_generation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=library_agents,
|
||||||
|
):
|
||||||
|
with patch.object(
|
||||||
|
core,
|
||||||
|
"search_marketplace_agents_for_generation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_marketplace:
|
||||||
|
result = await core.get_all_relevant_agents_for_generation(
|
||||||
|
user_id="user-123",
|
||||||
|
search_query="test",
|
||||||
|
include_marketplace=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Marketplace should not be called
|
||||||
|
mock_marketplace.assert_not_called()
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_skips_marketplace_when_no_search_query(self):
|
||||||
|
"""Test that marketplace is not searched without a search query."""
|
||||||
|
library_agents = [
|
||||||
|
{
|
||||||
|
"graph_id": "lib-123",
|
||||||
|
"graph_version": 1,
|
||||||
|
"name": "Library Agent",
|
||||||
|
"description": "From library",
|
||||||
|
"input_schema": {},
|
||||||
|
"output_schema": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
core,
|
||||||
|
"get_library_agents_for_generation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=library_agents,
|
||||||
|
):
|
||||||
|
with patch.object(
|
||||||
|
core,
|
||||||
|
"search_marketplace_agents_for_generation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_marketplace:
|
||||||
|
result = await core.get_all_relevant_agents_for_generation(
|
||||||
|
user_id="user-123",
|
||||||
|
search_query=None, # No search query
|
||||||
|
include_marketplace=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Marketplace should not be called without search query
|
||||||
|
mock_marketplace.assert_not_called()
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractSearchTermsFromSteps:
|
||||||
|
"""Test extract_search_terms_from_steps function."""
|
||||||
|
|
||||||
|
def test_extracts_terms_from_instructions_type(self):
|
||||||
|
"""Test extraction from valid instructions decomposition result."""
|
||||||
|
decomposition_result = {
|
||||||
|
"type": "instructions",
|
||||||
|
"steps": [
|
||||||
|
{
|
||||||
|
"description": "Send an email notification",
|
||||||
|
"block_name": "GmailSendBlock",
|
||||||
|
},
|
||||||
|
{"description": "Fetch weather data", "action": "Get weather API"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = core.extract_search_terms_from_steps(decomposition_result)
|
||||||
|
|
||||||
|
assert "Send an email notification" in result
|
||||||
|
assert "GmailSendBlock" in result
|
||||||
|
assert "Fetch weather data" in result
|
||||||
|
assert "Get weather API" in result
|
||||||
|
|
||||||
|
def test_returns_empty_for_non_instructions_type(self):
|
||||||
|
"""Test that non-instructions types return empty list."""
|
||||||
|
decomposition_result = {
|
||||||
|
"type": "clarifying_questions",
|
||||||
|
"questions": [{"question": "What email?"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = core.extract_search_terms_from_steps(decomposition_result)
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_deduplicates_terms_case_insensitively(self):
|
||||||
|
"""Test that duplicate terms are removed (case-insensitive)."""
|
||||||
|
decomposition_result = {
|
||||||
|
"type": "instructions",
|
||||||
|
"steps": [
|
||||||
|
{"description": "Send Email", "name": "send email"},
|
||||||
|
{"description": "Other task"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = core.extract_search_terms_from_steps(decomposition_result)
|
||||||
|
|
||||||
|
# Should only have one "send email" variant
|
||||||
|
email_terms = [t for t in result if "email" in t.lower()]
|
||||||
|
assert len(email_terms) == 1
|
||||||
|
|
||||||
|
def test_filters_short_terms(self):
|
||||||
|
"""Test that terms with 3 or fewer characters are filtered out."""
|
||||||
|
decomposition_result = {
|
||||||
|
"type": "instructions",
|
||||||
|
"steps": [
|
||||||
|
{"description": "ab", "action": "xyz"}, # Both too short
|
||||||
|
{"description": "Valid term here"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = core.extract_search_terms_from_steps(decomposition_result)
|
||||||
|
|
||||||
|
assert "ab" not in result
|
||||||
|
assert "xyz" not in result
|
||||||
|
assert "Valid term here" in result
|
||||||
|
|
||||||
|
def test_handles_empty_steps(self):
|
||||||
|
"""Test handling of empty steps list."""
|
||||||
|
decomposition_result = {
|
||||||
|
"type": "instructions",
|
||||||
|
"steps": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = core.extract_search_terms_from_steps(decomposition_result)
|
||||||
|
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
|
||||||
|
class TestEnrichLibraryAgentsFromSteps:
|
||||||
|
"""Test enrich_library_agents_from_steps function."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_enriches_with_additional_agents(self):
|
||||||
|
"""Test that additional agents are found based on steps."""
|
||||||
|
existing_agents = [
|
||||||
|
{
|
||||||
|
"graph_id": "existing-123",
|
||||||
|
"graph_version": 1,
|
||||||
|
"name": "Existing Agent",
|
||||||
|
"description": "Already fetched",
|
||||||
|
"input_schema": {},
|
||||||
|
"output_schema": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
additional_agents = [
|
||||||
|
{
|
||||||
|
"graph_id": "new-456",
|
||||||
|
"graph_version": 1,
|
||||||
|
"name": "Email Agent",
|
||||||
|
"description": "For sending emails",
|
||||||
|
"input_schema": {},
|
||||||
|
"output_schema": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
decomposition_result = {
|
||||||
|
"type": "instructions",
|
||||||
|
"steps": [
|
||||||
|
{"description": "Send email notification"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
core,
|
||||||
|
"get_all_relevant_agents_for_generation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=additional_agents,
|
||||||
|
):
|
||||||
|
result = await core.enrich_library_agents_from_steps(
|
||||||
|
user_id="user-123",
|
||||||
|
decomposition_result=decomposition_result,
|
||||||
|
existing_agents=existing_agents,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should have both existing and new agents
|
||||||
|
assert len(result) == 2
|
||||||
|
names = [a["name"] for a in result]
|
||||||
|
assert "Existing Agent" in names
|
||||||
|
assert "Email Agent" in names
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deduplicates_by_graph_id(self):
|
||||||
|
"""Test that agents with same graph_id are not duplicated."""
|
||||||
|
existing_agents = [
|
||||||
|
{
|
||||||
|
"graph_id": "agent-123",
|
||||||
|
"graph_version": 1,
|
||||||
|
"name": "Existing Agent",
|
||||||
|
"description": "Already fetched",
|
||||||
|
"input_schema": {},
|
||||||
|
"output_schema": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Additional search returns same agent
|
||||||
|
additional_agents = [
|
||||||
|
{
|
||||||
|
"graph_id": "agent-123", # Same ID
|
||||||
|
"graph_version": 1,
|
||||||
|
"name": "Existing Agent Copy",
|
||||||
|
"description": "Same agent different name",
|
||||||
|
"input_schema": {},
|
||||||
|
"output_schema": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
decomposition_result = {
|
||||||
|
"type": "instructions",
|
||||||
|
"steps": [{"description": "Some action"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
core,
|
||||||
|
"get_all_relevant_agents_for_generation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=additional_agents,
|
||||||
|
):
|
||||||
|
result = await core.enrich_library_agents_from_steps(
|
||||||
|
user_id="user-123",
|
||||||
|
decomposition_result=decomposition_result,
|
||||||
|
existing_agents=existing_agents,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should not duplicate
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_deduplicates_by_name(self):
|
||||||
|
"""Test that agents with same name are not duplicated."""
|
||||||
|
existing_agents = [
|
||||||
|
{
|
||||||
|
"graph_id": "agent-123",
|
||||||
|
"graph_version": 1,
|
||||||
|
"name": "Email Agent",
|
||||||
|
"description": "Already fetched",
|
||||||
|
"input_schema": {},
|
||||||
|
"output_schema": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Additional search returns agent with same name but different ID
|
||||||
|
additional_agents = [
|
||||||
|
{
|
||||||
|
"graph_id": "agent-456", # Different ID
|
||||||
|
"graph_version": 1,
|
||||||
|
"name": "Email Agent", # Same name
|
||||||
|
"description": "Different agent same name",
|
||||||
|
"input_schema": {},
|
||||||
|
"output_schema": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
decomposition_result = {
|
||||||
|
"type": "instructions",
|
||||||
|
"steps": [{"description": "Send email"}],
|
||||||
|
}
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
core,
|
||||||
|
"get_all_relevant_agents_for_generation",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=additional_agents,
|
||||||
|
):
|
||||||
|
result = await core.enrich_library_agents_from_steps(
|
||||||
|
user_id="user-123",
|
||||||
|
decomposition_result=decomposition_result,
|
||||||
|
existing_agents=existing_agents,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should not duplicate by name
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].get("graph_id") == "agent-123" # Original kept
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_existing_when_no_steps(self):
|
||||||
|
"""Test that existing agents are returned when no search terms extracted."""
|
||||||
|
existing_agents = [
|
||||||
|
{
|
||||||
|
"graph_id": "existing-123",
|
||||||
|
"graph_version": 1,
|
||||||
|
"name": "Existing Agent",
|
||||||
|
"description": "Already fetched",
|
||||||
|
"input_schema": {},
|
||||||
|
"output_schema": {},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
decomposition_result = {
|
||||||
|
"type": "clarifying_questions", # Not instructions type
|
||||||
|
"questions": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await core.enrich_library_agents_from_steps(
|
||||||
|
user_id="user-123",
|
||||||
|
decomposition_result=decomposition_result,
|
||||||
|
existing_agents=existing_agents,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should return existing unchanged
|
||||||
|
assert result == existing_agents
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_limits_search_terms_to_three(self):
|
||||||
|
"""Test that only first 3 search terms are used."""
|
||||||
|
existing_agents = []
|
||||||
|
|
||||||
|
decomposition_result = {
|
||||||
|
"type": "instructions",
|
||||||
|
"steps": [
|
||||||
|
{"description": "First action"},
|
||||||
|
{"description": "Second action"},
|
||||||
|
{"description": "Third action"},
|
||||||
|
{"description": "Fourth action"},
|
||||||
|
{"description": "Fifth action"},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
call_count = 0
|
||||||
|
|
||||||
|
async def mock_get_agents(*args, **kwargs):
|
||||||
|
nonlocal call_count
|
||||||
|
call_count += 1
|
||||||
|
return []
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
core,
|
||||||
|
"get_all_relevant_agents_for_generation",
|
||||||
|
side_effect=mock_get_agents,
|
||||||
|
):
|
||||||
|
await core.enrich_library_agents_from_steps(
|
||||||
|
user_id="user-123",
|
||||||
|
decomposition_result=decomposition_result,
|
||||||
|
existing_agents=existing_agents,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Should only make 3 calls (limited to first 3 terms)
|
||||||
|
assert call_count == 3
|
||||||
|
|
||||||
|
|
||||||
|
class TestExtractUuidsFromText:
|
||||||
|
"""Test extract_uuids_from_text function."""
|
||||||
|
|
||||||
|
def test_extracts_single_uuid(self):
|
||||||
|
"""Test extraction of a single UUID from text."""
|
||||||
|
text = "Use my agent 46631191-e8a8-486f-ad90-84f89738321d for this task"
|
||||||
|
result = core.extract_uuids_from_text(text)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert "46631191-e8a8-486f-ad90-84f89738321d" in result
|
||||||
|
|
||||||
|
def test_extracts_multiple_uuids(self):
|
||||||
|
"""Test extraction of multiple UUIDs from text."""
|
||||||
|
text = (
|
||||||
|
"Combine agents 11111111-1111-4111-8111-111111111111 "
|
||||||
|
"and 22222222-2222-4222-9222-222222222222"
|
||||||
|
)
|
||||||
|
result = core.extract_uuids_from_text(text)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert "11111111-1111-4111-8111-111111111111" in result
|
||||||
|
assert "22222222-2222-4222-9222-222222222222" in result
|
||||||
|
|
||||||
|
def test_deduplicates_uuids(self):
|
||||||
|
"""Test that duplicate UUIDs are deduplicated."""
|
||||||
|
text = (
|
||||||
|
"Use 46631191-e8a8-486f-ad90-84f89738321d twice: "
|
||||||
|
"46631191-e8a8-486f-ad90-84f89738321d"
|
||||||
|
)
|
||||||
|
result = core.extract_uuids_from_text(text)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_normalizes_to_lowercase(self):
|
||||||
|
"""Test that UUIDs are normalized to lowercase."""
|
||||||
|
text = "Use 46631191-E8A8-486F-AD90-84F89738321D"
|
||||||
|
result = core.extract_uuids_from_text(text)
|
||||||
|
assert result[0] == "46631191-e8a8-486f-ad90-84f89738321d"
|
||||||
|
|
||||||
|
def test_returns_empty_for_no_uuids(self):
|
||||||
|
"""Test that empty list is returned when no UUIDs found."""
|
||||||
|
text = "Create an email agent that sends notifications"
|
||||||
|
result = core.extract_uuids_from_text(text)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_ignores_invalid_uuids(self):
|
||||||
|
"""Test that invalid UUID-like strings are ignored."""
|
||||||
|
text = "Not a valid UUID: 12345678-1234-1234-1234-123456789abc"
|
||||||
|
result = core.extract_uuids_from_text(text)
|
||||||
|
# UUID v4 requires specific patterns (4 in third group, 8/9/a/b in fourth)
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetLibraryAgentById:
|
||||||
|
"""Test get_library_agent_by_id function (and its alias get_library_agent_by_graph_id)."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_agent_when_found_by_graph_id(self):
|
||||||
|
"""Test that agent is returned when found by graph_id."""
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_agent.graph_id = "agent-123"
|
||||||
|
mock_agent.graph_version = 1
|
||||||
|
mock_agent.name = "Test Agent"
|
||||||
|
mock_agent.description = "Test description"
|
||||||
|
mock_agent.input_schema = {"properties": {}}
|
||||||
|
mock_agent.output_schema = {"properties": {}}
|
||||||
|
|
||||||
|
with patch.object(
|
||||||
|
core.library_db,
|
||||||
|
"get_library_agent_by_graph_id",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_agent,
|
||||||
|
):
|
||||||
|
result = await core.get_library_agent_by_id("user-123", "agent-123")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result["graph_id"] == "agent-123"
|
||||||
|
assert result["name"] == "Test Agent"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_falls_back_to_library_agent_id(self):
|
||||||
|
"""Test that lookup falls back to library agent ID when graph_id not found."""
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_agent.graph_id = "graph-456" # Different from the lookup ID
|
||||||
|
mock_agent.graph_version = 1
|
||||||
|
mock_agent.name = "Library Agent"
|
||||||
|
mock_agent.description = "Found by library ID"
|
||||||
|
mock_agent.input_schema = {"properties": {}}
|
||||||
|
mock_agent.output_schema = {"properties": {}}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
core.library_db,
|
||||||
|
"get_library_agent_by_graph_id",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=None, # Not found by graph_id
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
core.library_db,
|
||||||
|
"get_library_agent",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_agent, # Found by library ID
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await core.get_library_agent_by_id("user-123", "library-id-123")
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
assert result["graph_id"] == "graph-456"
|
||||||
|
assert result["name"] == "Library Agent"
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_none_when_not_found_by_either_method(self):
|
||||||
|
"""Test that None is returned when agent not found by either method."""
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
core.library_db,
|
||||||
|
"get_library_agent_by_graph_id",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=None,
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
core.library_db,
|
||||||
|
"get_library_agent",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=core.NotFoundError("Not found"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await core.get_library_agent_by_id("user-123", "nonexistent")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_returns_none_on_exception(self):
|
||||||
|
"""Test that None is returned when exception occurs in both lookups."""
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
core.library_db,
|
||||||
|
"get_library_agent_by_graph_id",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=Exception("Database error"),
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
core.library_db,
|
||||||
|
"get_library_agent",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
side_effect=Exception("Database error"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await core.get_library_agent_by_id("user-123", "agent-123")
|
||||||
|
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_alias_works(self):
|
||||||
|
"""Test that get_library_agent_by_graph_id is an alias for get_library_agent_by_id."""
|
||||||
|
assert core.get_library_agent_by_graph_id is core.get_library_agent_by_id
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetAllRelevantAgentsWithUuids:
|
||||||
|
"""Test UUID extraction in get_all_relevant_agents_for_generation."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_fetches_explicitly_mentioned_agents(self):
|
||||||
|
"""Test that agents mentioned by UUID are fetched directly."""
|
||||||
|
mock_agent = MagicMock()
|
||||||
|
mock_agent.graph_id = "46631191-e8a8-486f-ad90-84f89738321d"
|
||||||
|
mock_agent.graph_version = 1
|
||||||
|
mock_agent.name = "Mentioned Agent"
|
||||||
|
mock_agent.description = "Explicitly mentioned"
|
||||||
|
mock_agent.input_schema = {}
|
||||||
|
mock_agent.output_schema = {}
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.agents = []
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
core.library_db,
|
||||||
|
"get_library_agent_by_graph_id",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_agent,
|
||||||
|
),
|
||||||
|
patch.object(
|
||||||
|
core.library_db,
|
||||||
|
"list_library_agents",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
return_value=mock_response,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = await core.get_all_relevant_agents_for_generation(
|
||||||
|
user_id="user-123",
|
||||||
|
search_query="Use agent 46631191-e8a8-486f-ad90-84f89738321d",
|
||||||
|
include_marketplace=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0].get("graph_id") == "46631191-e8a8-486f-ad90-84f89738321d"
|
||||||
|
assert result[0].get("name") == "Mentioned Agent"
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
pytest.main([__file__, "-v"])
|
||||||
@@ -433,5 +433,139 @@ class TestGetBlocksExternal:
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestLibraryAgentsPassthrough:
|
||||||
|
"""Test that library_agents are passed correctly in all requests."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Reset client singleton before each test."""
|
||||||
|
service._settings = None
|
||||||
|
service._client = None
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_decompose_goal_passes_library_agents(self):
|
||||||
|
"""Test that library_agents are included in decompose goal payload."""
|
||||||
|
library_agents = [
|
||||||
|
{
|
||||||
|
"graph_id": "agent-123",
|
||||||
|
"graph_version": 1,
|
||||||
|
"name": "Email Sender",
|
||||||
|
"description": "Sends emails",
|
||||||
|
"input_schema": {"properties": {"to": {"type": "string"}}},
|
||||||
|
"output_schema": {"properties": {"sent": {"type": "boolean"}}},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"type": "instructions",
|
||||||
|
"steps": ["Step 1"],
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
with patch.object(service, "_get_client", return_value=mock_client):
|
||||||
|
await service.decompose_goal_external(
|
||||||
|
"Send an email",
|
||||||
|
library_agents=library_agents,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify library_agents was passed in the payload
|
||||||
|
call_args = mock_client.post.call_args
|
||||||
|
assert call_args[1]["json"]["library_agents"] == library_agents
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_agent_passes_library_agents(self):
|
||||||
|
"""Test that library_agents are included in generate agent payload."""
|
||||||
|
library_agents = [
|
||||||
|
{
|
||||||
|
"graph_id": "agent-456",
|
||||||
|
"graph_version": 2,
|
||||||
|
"name": "Data Fetcher",
|
||||||
|
"description": "Fetches data from API",
|
||||||
|
"input_schema": {"properties": {"url": {"type": "string"}}},
|
||||||
|
"output_schema": {"properties": {"data": {"type": "object"}}},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"agent_json": {"name": "Test Agent", "nodes": []},
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
with patch.object(service, "_get_client", return_value=mock_client):
|
||||||
|
await service.generate_agent_external(
|
||||||
|
{"steps": ["Step 1"]},
|
||||||
|
library_agents=library_agents,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify library_agents was passed in the payload
|
||||||
|
call_args = mock_client.post.call_args
|
||||||
|
assert call_args[1]["json"]["library_agents"] == library_agents
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_generate_agent_patch_passes_library_agents(self):
|
||||||
|
"""Test that library_agents are included in patch generation payload."""
|
||||||
|
library_agents = [
|
||||||
|
{
|
||||||
|
"graph_id": "agent-789",
|
||||||
|
"graph_version": 1,
|
||||||
|
"name": "Slack Notifier",
|
||||||
|
"description": "Sends Slack messages",
|
||||||
|
"input_schema": {"properties": {"message": {"type": "string"}}},
|
||||||
|
"output_schema": {"properties": {"success": {"type": "boolean"}}},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"agent_json": {"name": "Updated Agent", "nodes": []},
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
with patch.object(service, "_get_client", return_value=mock_client):
|
||||||
|
await service.generate_agent_patch_external(
|
||||||
|
"Add error handling",
|
||||||
|
{"name": "Original Agent", "nodes": []},
|
||||||
|
library_agents=library_agents,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Verify library_agents was passed in the payload
|
||||||
|
call_args = mock_client.post.call_args
|
||||||
|
assert call_args[1]["json"]["library_agents"] == library_agents
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_decompose_goal_without_library_agents(self):
|
||||||
|
"""Test that decompose goal works without library_agents."""
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.json.return_value = {
|
||||||
|
"success": True,
|
||||||
|
"type": "instructions",
|
||||||
|
"steps": ["Step 1"],
|
||||||
|
}
|
||||||
|
mock_response.raise_for_status = MagicMock()
|
||||||
|
|
||||||
|
mock_client = AsyncMock()
|
||||||
|
mock_client.post.return_value = mock_response
|
||||||
|
|
||||||
|
with patch.object(service, "_get_client", return_value=mock_client):
|
||||||
|
await service.decompose_goal_external("Build a workflow")
|
||||||
|
|
||||||
|
# Verify library_agents was NOT passed when not provided
|
||||||
|
call_args = mock_client.post.call_args
|
||||||
|
assert "library_agents" not in call_args[1]["json"]
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
pytest.main([__file__, "-v"])
|
pytest.main([__file__, "-v"])
|
||||||
|
|||||||
@@ -857,7 +857,7 @@ export const CustomNode = React.memo(
|
|||||||
})();
|
})();
|
||||||
|
|
||||||
const hasAdvancedFields =
|
const hasAdvancedFields =
|
||||||
data.inputSchema &&
|
data.inputSchema?.properties &&
|
||||||
Object.entries(data.inputSchema.properties).some(([key, value]) => {
|
Object.entries(data.inputSchema.properties).some(([key, value]) => {
|
||||||
return (
|
return (
|
||||||
value.advanced === true && !data.inputSchema.required?.includes(key)
|
value.advanced === true && !data.inputSchema.required?.includes(key)
|
||||||
|
|||||||
@@ -1,185 +0,0 @@
|
|||||||
import { describe, expect, test, afterEach } from "vitest";
|
|
||||||
import { render, screen, waitFor } from "@/tests/integrations/test-utils";
|
|
||||||
import { FavoritesSection } from "../FavoritesSection/FavoritesSection";
|
|
||||||
import { server } from "@/mocks/mock-server";
|
|
||||||
import { http, HttpResponse } from "msw";
|
|
||||||
import {
|
|
||||||
mockAuthenticatedUser,
|
|
||||||
resetAuthState,
|
|
||||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
|
||||||
|
|
||||||
const mockFavoriteAgent = {
|
|
||||||
id: "fav-agent-id",
|
|
||||||
graph_id: "fav-graph-id",
|
|
||||||
graph_version: 1,
|
|
||||||
owner_user_id: "test-owner-id",
|
|
||||||
image_url: null,
|
|
||||||
creator_name: "Test Creator",
|
|
||||||
creator_image_url: "https://example.com/avatar.png",
|
|
||||||
status: "READY",
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
name: "Favorite Agent Name",
|
|
||||||
description: "Test favorite agent",
|
|
||||||
input_schema: {},
|
|
||||||
output_schema: {},
|
|
||||||
credentials_input_schema: null,
|
|
||||||
has_external_trigger: false,
|
|
||||||
has_human_in_the_loop: false,
|
|
||||||
has_sensitive_action: false,
|
|
||||||
new_output: false,
|
|
||||||
can_access_graph: true,
|
|
||||||
is_latest_version: true,
|
|
||||||
is_favorite: true,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("FavoritesSection", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
resetAuthState();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders favorites section when there are favorites", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.get("*/api/library/agents/favorites*", () => {
|
|
||||||
return HttpResponse.json({
|
|
||||||
agents: [mockFavoriteAgent],
|
|
||||||
pagination: {
|
|
||||||
total_items: 1,
|
|
||||||
total_pages: 1,
|
|
||||||
current_page: 1,
|
|
||||||
page_size: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<FavoritesSection searchTerm="" />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/favorites/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders favorite agent cards", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.get("*/api/library/agents/favorites*", () => {
|
|
||||||
return HttpResponse.json({
|
|
||||||
agents: [mockFavoriteAgent],
|
|
||||||
pagination: {
|
|
||||||
total_items: 1,
|
|
||||||
total_pages: 1,
|
|
||||||
current_page: 1,
|
|
||||||
page_size: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<FavoritesSection searchTerm="" />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Favorite Agent Name")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows agent count", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.get("*/api/library/agents/favorites*", () => {
|
|
||||||
return HttpResponse.json({
|
|
||||||
agents: [mockFavoriteAgent],
|
|
||||||
pagination: {
|
|
||||||
total_items: 1,
|
|
||||||
total_pages: 1,
|
|
||||||
current_page: 1,
|
|
||||||
page_size: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<FavoritesSection searchTerm="" />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId("agents-count")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not render when there are no favorites", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.get("*/api/library/agents/favorites*", () => {
|
|
||||||
return HttpResponse.json({
|
|
||||||
agents: [],
|
|
||||||
pagination: {
|
|
||||||
total_items: 0,
|
|
||||||
total_pages: 0,
|
|
||||||
current_page: 1,
|
|
||||||
page_size: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { container } = render(<FavoritesSection searchTerm="" />);
|
|
||||||
|
|
||||||
// Wait for loading to complete
|
|
||||||
await waitFor(() => {
|
|
||||||
// Component should return null when no favorites
|
|
||||||
expect(container.textContent).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("filters favorites based on search term", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
|
|
||||||
// Mock that returns different results based on search term
|
|
||||||
server.use(
|
|
||||||
http.get("*/api/library/agents/favorites*", ({ request }) => {
|
|
||||||
const url = new URL(request.url);
|
|
||||||
const searchTerm = url.searchParams.get("search_term");
|
|
||||||
|
|
||||||
if (searchTerm === "nonexistent") {
|
|
||||||
return HttpResponse.json({
|
|
||||||
agents: [],
|
|
||||||
pagination: {
|
|
||||||
total_items: 0,
|
|
||||||
total_pages: 0,
|
|
||||||
current_page: 1,
|
|
||||||
page_size: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return HttpResponse.json({
|
|
||||||
agents: [mockFavoriteAgent],
|
|
||||||
pagination: {
|
|
||||||
total_items: 1,
|
|
||||||
total_pages: 1,
|
|
||||||
current_page: 1,
|
|
||||||
page_size: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { rerender } = render(<FavoritesSection searchTerm="" />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Favorite Agent Name")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Rerender with search term that yields no results
|
|
||||||
rerender(<FavoritesSection searchTerm="nonexistent" />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.queryByText("Favorite Agent Name")).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { describe, expect, test, afterEach } from "vitest";
|
|
||||||
import { render, screen } from "@/tests/integrations/test-utils";
|
|
||||||
import { LibraryAgentCard } from "../LibraryAgentCard/LibraryAgentCard";
|
|
||||||
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
|
|
||||||
import {
|
|
||||||
mockAuthenticatedUser,
|
|
||||||
resetAuthState,
|
|
||||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
|
||||||
|
|
||||||
const mockAgent: LibraryAgent = {
|
|
||||||
id: "test-agent-id",
|
|
||||||
graph_id: "test-graph-id",
|
|
||||||
graph_version: 1,
|
|
||||||
owner_user_id: "test-owner-id",
|
|
||||||
image_url: null,
|
|
||||||
creator_name: "Test Creator",
|
|
||||||
creator_image_url: "https://example.com/avatar.png",
|
|
||||||
status: "READY",
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
name: "Test Agent Name",
|
|
||||||
description: "Test agent description",
|
|
||||||
input_schema: {},
|
|
||||||
output_schema: {},
|
|
||||||
credentials_input_schema: null,
|
|
||||||
has_external_trigger: false,
|
|
||||||
has_human_in_the_loop: false,
|
|
||||||
has_sensitive_action: false,
|
|
||||||
new_output: false,
|
|
||||||
can_access_graph: true,
|
|
||||||
is_latest_version: true,
|
|
||||||
is_favorite: false,
|
|
||||||
};
|
|
||||||
|
|
||||||
describe("LibraryAgentCard", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
resetAuthState();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders agent name", () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryAgentCard agent={mockAgent} />);
|
|
||||||
|
|
||||||
expect(screen.getByText("Test Agent Name")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders see runs link", () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryAgentCard agent={mockAgent} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/see runs/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders open in builder link when can_access_graph is true", () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryAgentCard agent={mockAgent} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/open in builder/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("does not render open in builder link when can_access_graph is false", () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
const agentWithoutAccess = { ...mockAgent, can_access_graph: false };
|
|
||||||
render(<LibraryAgentCard agent={agentWithoutAccess} />);
|
|
||||||
|
|
||||||
expect(screen.queryByText(/open in builder/i)).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows 'FROM MARKETPLACE' label for marketplace agents", () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
const marketplaceAgent = {
|
|
||||||
...mockAgent,
|
|
||||||
marketplace_listing: {
|
|
||||||
id: "listing-id",
|
|
||||||
name: "Marketplace Agent",
|
|
||||||
slug: "marketplace-agent",
|
|
||||||
creator: {
|
|
||||||
id: "creator-id",
|
|
||||||
name: "Creator Name",
|
|
||||||
slug: "creator-slug",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
render(<LibraryAgentCard agent={marketplaceAgent} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/from marketplace/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows 'Built by you' label for user's own agents", () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryAgentCard agent={mockAgent} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/built by you/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders favorite button", () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryAgentCard agent={mockAgent} />);
|
|
||||||
|
|
||||||
// The favorite button should be present (as a heart icon button)
|
|
||||||
const card = screen.getByTestId("library-agent-card");
|
|
||||||
expect(card).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("links to correct agent detail page", () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryAgentCard agent={mockAgent} />);
|
|
||||||
|
|
||||||
const link = screen.getByTestId("library-agent-card-see-runs-link");
|
|
||||||
expect(link).toHaveAttribute("href", "/library/agents/test-agent-id");
|
|
||||||
});
|
|
||||||
|
|
||||||
test("links to correct builder page", () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryAgentCard agent={mockAgent} />);
|
|
||||||
|
|
||||||
const builderLink = screen.getByTestId(
|
|
||||||
"library-agent-card-open-in-builder-link",
|
|
||||||
);
|
|
||||||
expect(builderLink).toHaveAttribute("href", "/build?flowID=test-graph-id");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
|
||||||
import { render, screen, fireEvent, waitFor } from "@/tests/integrations/test-utils";
|
|
||||||
import { LibrarySearchBar } from "../LibrarySearchBar/LibrarySearchBar";
|
|
||||||
|
|
||||||
describe("LibrarySearchBar", () => {
|
|
||||||
test("renders search input", () => {
|
|
||||||
const setSearchTerm = vi.fn();
|
|
||||||
render(<LibrarySearchBar setSearchTerm={setSearchTerm} />);
|
|
||||||
|
|
||||||
expect(screen.getByPlaceholderText(/search agents/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders search icon", () => {
|
|
||||||
const setSearchTerm = vi.fn();
|
|
||||||
const { container } = render(
|
|
||||||
<LibrarySearchBar setSearchTerm={setSearchTerm} />,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Check for the magnifying glass icon (SVG element)
|
|
||||||
const searchIcon = container.querySelector("svg");
|
|
||||||
expect(searchIcon).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("calls setSearchTerm on input change", async () => {
|
|
||||||
const setSearchTerm = vi.fn();
|
|
||||||
render(<LibrarySearchBar setSearchTerm={setSearchTerm} />);
|
|
||||||
|
|
||||||
const input = screen.getByPlaceholderText(/search agents/i);
|
|
||||||
fireEvent.change(input, { target: { value: "test query" } });
|
|
||||||
|
|
||||||
// The search bar uses debouncing, so we need to wait
|
|
||||||
await waitFor(
|
|
||||||
() => {
|
|
||||||
expect(setSearchTerm).toHaveBeenCalled();
|
|
||||||
},
|
|
||||||
{ timeout: 1000 },
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has correct test id", () => {
|
|
||||||
const setSearchTerm = vi.fn();
|
|
||||||
render(<LibrarySearchBar setSearchTerm={setSearchTerm} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("search-bar")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("input has correct test id", () => {
|
|
||||||
const setSearchTerm = vi.fn();
|
|
||||||
render(<LibrarySearchBar setSearchTerm={setSearchTerm} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("library-textbox")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
import { describe, expect, test, vi } from "vitest";
|
|
||||||
import { render, screen, fireEvent, waitFor } from "@/tests/integrations/test-utils";
|
|
||||||
import { LibrarySortMenu } from "../LibrarySortMenu/LibrarySortMenu";
|
|
||||||
|
|
||||||
describe("LibrarySortMenu", () => {
|
|
||||||
test("renders sort dropdown", () => {
|
|
||||||
const setLibrarySort = vi.fn();
|
|
||||||
render(<LibrarySortMenu setLibrarySort={setLibrarySort} />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("sort-by-dropdown")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows 'sort by' label on larger screens", () => {
|
|
||||||
const setLibrarySort = vi.fn();
|
|
||||||
render(<LibrarySortMenu setLibrarySort={setLibrarySort} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/sort by/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows default placeholder text", () => {
|
|
||||||
const setLibrarySort = vi.fn();
|
|
||||||
render(<LibrarySortMenu setLibrarySort={setLibrarySort} />);
|
|
||||||
|
|
||||||
expect(screen.getByText(/last modified/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens dropdown when clicked", async () => {
|
|
||||||
const setLibrarySort = vi.fn();
|
|
||||||
render(<LibrarySortMenu setLibrarySort={setLibrarySort} />);
|
|
||||||
|
|
||||||
const trigger = screen.getByRole("combobox");
|
|
||||||
fireEvent.click(trigger);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/creation date/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows both sort options in dropdown", async () => {
|
|
||||||
const setLibrarySort = vi.fn();
|
|
||||||
render(<LibrarySortMenu setLibrarySort={setLibrarySort} />);
|
|
||||||
|
|
||||||
const trigger = screen.getByRole("combobox");
|
|
||||||
fireEvent.click(trigger);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(/creation date/i)).toBeInTheDocument();
|
|
||||||
expect(
|
|
||||||
screen.getAllByText(/last modified/i).length,
|
|
||||||
).toBeGreaterThanOrEqual(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { describe, expect, test, afterEach } from "vitest";
|
|
||||||
import { render, screen, fireEvent, waitFor } from "@/tests/integrations/test-utils";
|
|
||||||
import LibraryUploadAgentDialog from "../LibraryUploadAgentDialog/LibraryUploadAgentDialog";
|
|
||||||
import {
|
|
||||||
mockAuthenticatedUser,
|
|
||||||
resetAuthState,
|
|
||||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
|
||||||
|
|
||||||
describe("LibraryUploadAgentDialog", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
resetAuthState();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders upload button", () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryUploadAgentDialog />);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: /upload agent/i }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("opens dialog when upload button is clicked", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryUploadAgentDialog />);
|
|
||||||
|
|
||||||
const uploadButton = screen.getByRole("button", { name: /upload agent/i });
|
|
||||||
fireEvent.click(uploadButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText("Upload Agent")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dialog contains agent name input", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryUploadAgentDialog />);
|
|
||||||
|
|
||||||
const uploadButton = screen.getByRole("button", { name: /upload agent/i });
|
|
||||||
fireEvent.click(uploadButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText(/agent name/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("dialog contains agent description input", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryUploadAgentDialog />);
|
|
||||||
|
|
||||||
const uploadButton = screen.getByRole("button", { name: /upload agent/i });
|
|
||||||
fireEvent.click(uploadButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByLabelText(/agent description/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("upload button is disabled when form is incomplete", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryUploadAgentDialog />);
|
|
||||||
|
|
||||||
const triggerButton = screen.getByRole("button", { name: /upload agent/i });
|
|
||||||
fireEvent.click(triggerButton);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const submitButton = screen.getByRole("button", { name: /^upload$/i });
|
|
||||||
expect(submitButton).toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("has correct test id on trigger button", () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryUploadAgentDialog />);
|
|
||||||
|
|
||||||
expect(screen.getByTestId("upload-agent-button")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
import { describe, expect, test, afterEach } from "vitest";
|
|
||||||
import { render, screen } from "@/tests/integrations/test-utils";
|
|
||||||
import LibraryPage from "../../page";
|
|
||||||
import {
|
|
||||||
mockAuthenticatedUser,
|
|
||||||
mockUnauthenticatedUser,
|
|
||||||
resetAuthState,
|
|
||||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
|
||||||
|
|
||||||
describe("LibraryPage - Auth State", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
resetAuthState();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders page correctly when logged in", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryPage />);
|
|
||||||
|
|
||||||
// Wait for upload button text to appear (indicates page is rendered)
|
|
||||||
expect(
|
|
||||||
await screen.findByText("Upload agent", { exact: false }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Search bar should be visible
|
|
||||||
expect(screen.getByTestId("search-bar")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders page correctly when logged out", async () => {
|
|
||||||
mockUnauthenticatedUser();
|
|
||||||
render(<LibraryPage />);
|
|
||||||
|
|
||||||
// Wait for upload button text to appear (indicates page is rendered)
|
|
||||||
expect(
|
|
||||||
await screen.findByText("Upload agent", { exact: false }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Search bar should still be visible
|
|
||||||
expect(screen.getByTestId("search-bar")).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
import { describe, expect, test, afterEach } from "vitest";
|
|
||||||
import { render, screen, waitFor } from "@/tests/integrations/test-utils";
|
|
||||||
import LibraryPage from "../../page";
|
|
||||||
import { server } from "@/mocks/mock-server";
|
|
||||||
import { http, HttpResponse } from "msw";
|
|
||||||
import {
|
|
||||||
mockAuthenticatedUser,
|
|
||||||
resetAuthState,
|
|
||||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
|
||||||
|
|
||||||
describe("LibraryPage - Empty State", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
resetAuthState();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles empty agents list gracefully", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.get("*/api/library/agents*", () => {
|
|
||||||
return HttpResponse.json({
|
|
||||||
agents: [],
|
|
||||||
pagination: {
|
|
||||||
total_items: 0,
|
|
||||||
total_pages: 0,
|
|
||||||
current_page: 1,
|
|
||||||
page_size: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
http.get("*/api/library/agents/favorites*", () => {
|
|
||||||
return HttpResponse.json({
|
|
||||||
agents: [],
|
|
||||||
pagination: {
|
|
||||||
total_items: 0,
|
|
||||||
total_pages: 0,
|
|
||||||
current_page: 1,
|
|
||||||
page_size: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<LibraryPage />);
|
|
||||||
|
|
||||||
// Page should still render without crashing
|
|
||||||
// Search bar should be visible even with no agents
|
|
||||||
expect(
|
|
||||||
await screen.findByPlaceholderText(/search agents/i),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Upload button should be visible
|
|
||||||
expect(
|
|
||||||
screen.getByRole("button", { name: /upload agent/i }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles empty favorites gracefully", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
|
|
||||||
server.use(
|
|
||||||
http.get("*/api/library/agents/favorites*", () => {
|
|
||||||
return HttpResponse.json({
|
|
||||||
agents: [],
|
|
||||||
pagination: {
|
|
||||||
total_items: 0,
|
|
||||||
total_pages: 0,
|
|
||||||
current_page: 1,
|
|
||||||
page_size: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(<LibraryPage />);
|
|
||||||
|
|
||||||
// Page should still render without crashing
|
|
||||||
expect(
|
|
||||||
await screen.findByPlaceholderText(/search agents/i),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import { describe, expect, test, afterEach } from "vitest";
|
|
||||||
import { render, screen, waitFor } from "@/tests/integrations/test-utils";
|
|
||||||
import LibraryPage from "../../page";
|
|
||||||
import { server } from "@/mocks/mock-server";
|
|
||||||
import {
|
|
||||||
mockAuthenticatedUser,
|
|
||||||
resetAuthState,
|
|
||||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
|
||||||
import { create500Handler } from "@/tests/integrations/helpers/create-500-handler";
|
|
||||||
import {
|
|
||||||
getGetV2ListLibraryAgentsMockHandler422,
|
|
||||||
getGetV2ListFavoriteLibraryAgentsMockHandler422,
|
|
||||||
} from "@/app/api/__generated__/endpoints/library/library.msw";
|
|
||||||
|
|
||||||
describe("LibraryPage - Error Handling", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
resetAuthState();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles API 422 error gracefully", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
|
|
||||||
server.use(getGetV2ListLibraryAgentsMockHandler422());
|
|
||||||
|
|
||||||
render(<LibraryPage />);
|
|
||||||
|
|
||||||
// Page should still render without crashing
|
|
||||||
// Search bar should be visible even with error
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByPlaceholderText(/search agents/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles favorites API 422 error gracefully", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
|
|
||||||
server.use(getGetV2ListFavoriteLibraryAgentsMockHandler422());
|
|
||||||
|
|
||||||
render(<LibraryPage />);
|
|
||||||
|
|
||||||
// Page should still render without crashing
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByPlaceholderText(/search agents/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("handles API 500 error gracefully", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
|
|
||||||
server.use(create500Handler("get", "*/api/library/agents*"));
|
|
||||||
|
|
||||||
render(<LibraryPage />);
|
|
||||||
|
|
||||||
// Page should still render without crashing
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByPlaceholderText(/search agents/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
import { describe, expect, test, afterEach } from "vitest";
|
|
||||||
import { render, screen } from "@/tests/integrations/test-utils";
|
|
||||||
import LibraryPage from "../../page";
|
|
||||||
import { server } from "@/mocks/mock-server";
|
|
||||||
import { http, HttpResponse, delay } from "msw";
|
|
||||||
import {
|
|
||||||
mockAuthenticatedUser,
|
|
||||||
resetAuthState,
|
|
||||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
|
||||||
|
|
||||||
describe("LibraryPage - Loading State", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
resetAuthState();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("shows loading spinner while agents are being fetched", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
|
|
||||||
// Override handlers to add delay to simulate loading
|
|
||||||
server.use(
|
|
||||||
http.get("*/api/library/agents*", async () => {
|
|
||||||
await delay(500);
|
|
||||||
return HttpResponse.json({
|
|
||||||
agents: [],
|
|
||||||
pagination: {
|
|
||||||
total_items: 0,
|
|
||||||
total_pages: 0,
|
|
||||||
current_page: 1,
|
|
||||||
page_size: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
http.get("*/api/library/agents/favorites*", async () => {
|
|
||||||
await delay(500);
|
|
||||||
return HttpResponse.json({
|
|
||||||
agents: [],
|
|
||||||
pagination: {
|
|
||||||
total_items: 0,
|
|
||||||
total_pages: 0,
|
|
||||||
current_page: 1,
|
|
||||||
page_size: 20,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const { container } = render(<LibraryPage />);
|
|
||||||
|
|
||||||
// Check for loading spinner (LoadingSpinner component)
|
|
||||||
const loadingElements = container.querySelectorAll(
|
|
||||||
'[class*="animate-spin"]',
|
|
||||||
);
|
|
||||||
expect(loadingElements.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
import { describe, expect, test, afterEach } from "vitest";
|
|
||||||
import { render, screen, waitFor } from "@/tests/integrations/test-utils";
|
|
||||||
import LibraryPage from "../../page";
|
|
||||||
import {
|
|
||||||
mockAuthenticatedUser,
|
|
||||||
resetAuthState,
|
|
||||||
} from "@/tests/integrations/helpers/mock-supabase-auth";
|
|
||||||
|
|
||||||
describe("LibraryPage - Rendering", () => {
|
|
||||||
afterEach(() => {
|
|
||||||
resetAuthState();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders search bar", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryPage />);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await screen.findByPlaceholderText(/search agents/i),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders upload agent button", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryPage />);
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await screen.findByRole("button", { name: /upload agent/i }),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
test("renders agent cards when data is loaded", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryPage />);
|
|
||||||
|
|
||||||
// Wait for agent cards to appear (from mock data)
|
|
||||||
await waitFor(() => {
|
|
||||||
const agentCards = screen.getAllByTestId("library-agent-card");
|
|
||||||
expect(agentCards.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("agent cards display agent name", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryPage />);
|
|
||||||
|
|
||||||
// Wait for agent cards and check they have names
|
|
||||||
await waitFor(() => {
|
|
||||||
const agentNames = screen.getAllByTestId("library-agent-card-name");
|
|
||||||
expect(agentNames.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
test("agent cards have see runs link", async () => {
|
|
||||||
mockAuthenticatedUser();
|
|
||||||
render(<LibraryPage />);
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
const seeRunsLinks = screen.getAllByTestId(
|
|
||||||
"library-agent-card-see-runs-link",
|
|
||||||
);
|
|
||||||
expect(seeRunsLinks.length).toBeGreaterThan(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -7981,6 +7981,25 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"new_output": { "type": "boolean", "title": "New Output" },
|
"new_output": { "type": "boolean", "title": "New Output" },
|
||||||
|
"execution_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"title": "Execution Count",
|
||||||
|
"default": 0
|
||||||
|
},
|
||||||
|
"success_rate": {
|
||||||
|
"anyOf": [{ "type": "number" }, { "type": "null" }],
|
||||||
|
"title": "Success Rate"
|
||||||
|
},
|
||||||
|
"avg_correctness_score": {
|
||||||
|
"anyOf": [{ "type": "number" }, { "type": "null" }],
|
||||||
|
"title": "Avg Correctness Score"
|
||||||
|
},
|
||||||
|
"recent_executions": {
|
||||||
|
"items": { "$ref": "#/components/schemas/RecentExecution" },
|
||||||
|
"type": "array",
|
||||||
|
"title": "Recent Executions",
|
||||||
|
"description": "List of recent executions with status, score, and summary"
|
||||||
|
},
|
||||||
"can_access_graph": {
|
"can_access_graph": {
|
||||||
"type": "boolean",
|
"type": "boolean",
|
||||||
"title": "Can Access Graph"
|
"title": "Can Access Graph"
|
||||||
@@ -9374,6 +9393,23 @@
|
|||||||
"required": ["providers", "pagination"],
|
"required": ["providers", "pagination"],
|
||||||
"title": "ProviderResponse"
|
"title": "ProviderResponse"
|
||||||
},
|
},
|
||||||
|
"RecentExecution": {
|
||||||
|
"properties": {
|
||||||
|
"status": { "type": "string", "title": "Status" },
|
||||||
|
"correctness_score": {
|
||||||
|
"anyOf": [{ "type": "number" }, { "type": "null" }],
|
||||||
|
"title": "Correctness Score"
|
||||||
|
},
|
||||||
|
"activity_summary": {
|
||||||
|
"anyOf": [{ "type": "string" }, { "type": "null" }],
|
||||||
|
"title": "Activity Summary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": "object",
|
||||||
|
"required": ["status"],
|
||||||
|
"title": "RecentExecution",
|
||||||
|
"description": "Summary of a recent execution for quality assessment.\n\nUsed by the LLM to understand the agent's recent performance with specific examples\nrather than just aggregate statistics."
|
||||||
|
},
|
||||||
"RefundRequest": {
|
"RefundRequest": {
|
||||||
"properties": {
|
"properties": {
|
||||||
"id": { "type": "string", "title": "Id" },
|
"id": { "type": "string", "title": "Id" },
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
/**
|
|
||||||
* useChatContainerAiSdk - ChatContainer hook using Vercel AI SDK
|
|
||||||
*
|
|
||||||
* This is a drop-in replacement for useChatContainer that uses @ai-sdk/react
|
|
||||||
* instead of the custom streaming implementation. The API surface is identical
|
|
||||||
* to enable easy A/B testing and gradual migration.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { SessionDetailResponse } from "@/app/api/__generated__/models/sessionDetailResponse";
|
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
|
||||||
import type { UIMessage } from "ai";
|
|
||||||
import { useAiSdkChat } from "../../useAiSdkChat";
|
|
||||||
import { usePageContext } from "../../usePageContext";
|
|
||||||
import type { ChatMessageData } from "../ChatMessage/useChatMessage";
|
|
||||||
import {
|
|
||||||
filterAuthMessages,
|
|
||||||
hasSentInitialPrompt,
|
|
||||||
markInitialPromptSent,
|
|
||||||
processInitialMessages,
|
|
||||||
} from "./helpers";
|
|
||||||
|
|
||||||
// Helper to convert backend messages to AI SDK UIMessage format
|
|
||||||
function convertToUIMessages(
|
|
||||||
messages: SessionDetailResponse["messages"],
|
|
||||||
): UIMessage[] {
|
|
||||||
const result: UIMessage[] = [];
|
|
||||||
|
|
||||||
for (const msg of messages) {
|
|
||||||
if (!msg.role || !msg.content) continue;
|
|
||||||
|
|
||||||
// Create parts based on message type
|
|
||||||
const parts: UIMessage["parts"] = [];
|
|
||||||
|
|
||||||
if (msg.role === "user" || msg.role === "assistant") {
|
|
||||||
if (typeof msg.content === "string") {
|
|
||||||
parts.push({ type: "text", text: msg.content });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tool calls in assistant messages
|
|
||||||
if (msg.role === "assistant" && msg.tool_calls) {
|
|
||||||
for (const toolCall of msg.tool_calls as Array<{
|
|
||||||
id: string;
|
|
||||||
type: string;
|
|
||||||
function: { name: string; arguments: string };
|
|
||||||
}>) {
|
|
||||||
if (toolCall.type === "function") {
|
|
||||||
let args = {};
|
|
||||||
try {
|
|
||||||
args = JSON.parse(toolCall.function.arguments);
|
|
||||||
} catch {
|
|
||||||
// Keep empty args
|
|
||||||
}
|
|
||||||
parts.push({
|
|
||||||
type: `tool-${toolCall.function.name}` as `tool-${string}`,
|
|
||||||
toolCallId: toolCall.id,
|
|
||||||
toolName: toolCall.function.name,
|
|
||||||
state: "input-available",
|
|
||||||
input: args,
|
|
||||||
} as UIMessage["parts"][number]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tool responses
|
|
||||||
if (msg.role === "tool" && msg.tool_call_id) {
|
|
||||||
// Find the matching tool call to get the name
|
|
||||||
const toolName = "unknown";
|
|
||||||
let output: unknown = msg.content;
|
|
||||||
try {
|
|
||||||
output =
|
|
||||||
typeof msg.content === "string"
|
|
||||||
? JSON.parse(msg.content)
|
|
||||||
: msg.content;
|
|
||||||
} catch {
|
|
||||||
// Keep as string
|
|
||||||
}
|
|
||||||
|
|
||||||
parts.push({
|
|
||||||
type: `tool-${toolName}` as `tool-${string}`,
|
|
||||||
toolCallId: msg.tool_call_id as string,
|
|
||||||
toolName,
|
|
||||||
state: "output-available",
|
|
||||||
output,
|
|
||||||
} as UIMessage["parts"][number]);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parts.length > 0) {
|
|
||||||
result.push({
|
|
||||||
id: msg.id || `msg-${Date.now()}-${Math.random()}`,
|
|
||||||
role: msg.role === "tool" ? "assistant" : (msg.role as "user" | "assistant"),
|
|
||||||
parts,
|
|
||||||
createdAt: msg.created_at ? new Date(msg.created_at as string) : new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Args {
|
|
||||||
sessionId: string | null;
|
|
||||||
initialMessages: SessionDetailResponse["messages"];
|
|
||||||
initialPrompt?: string;
|
|
||||||
onOperationStarted?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useChatContainerAiSdk({
|
|
||||||
sessionId,
|
|
||||||
initialMessages,
|
|
||||||
initialPrompt,
|
|
||||||
onOperationStarted,
|
|
||||||
}: Args) {
|
|
||||||
const { capturePageContext } = usePageContext();
|
|
||||||
const sendMessageRef = useRef<
|
|
||||||
(
|
|
||||||
content: string,
|
|
||||||
isUserMessage?: boolean,
|
|
||||||
context?: { url: string; content: string },
|
|
||||||
) => Promise<void>
|
|
||||||
>();
|
|
||||||
|
|
||||||
// Convert initial messages to AI SDK format
|
|
||||||
const uiMessages = useMemo(
|
|
||||||
() => convertToUIMessages(initialMessages),
|
|
||||||
[initialMessages],
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
|
||||||
messages: aiSdkMessages,
|
|
||||||
streamingChunks,
|
|
||||||
isStreaming,
|
|
||||||
error,
|
|
||||||
isRegionBlockedModalOpen,
|
|
||||||
setIsRegionBlockedModalOpen,
|
|
||||||
sendMessage,
|
|
||||||
stopStreaming,
|
|
||||||
} = useAiSdkChat({
|
|
||||||
sessionId,
|
|
||||||
initialMessages: uiMessages,
|
|
||||||
onOperationStarted,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Keep ref updated for initial prompt handling
|
|
||||||
sendMessageRef.current = sendMessage;
|
|
||||||
|
|
||||||
// Merge AI SDK messages with processed initial messages
|
|
||||||
// This ensures we show both historical messages and new streaming messages
|
|
||||||
const allMessages = useMemo(() => {
|
|
||||||
const processedInitial = processInitialMessages(initialMessages);
|
|
||||||
|
|
||||||
// Build a set of message keys for deduplication
|
|
||||||
const seenKeys = new Set<string>();
|
|
||||||
const result: ChatMessageData[] = [];
|
|
||||||
|
|
||||||
// Add processed initial messages first
|
|
||||||
for (const msg of processedInitial) {
|
|
||||||
const key = getMessageKey(msg);
|
|
||||||
if (!seenKeys.has(key)) {
|
|
||||||
seenKeys.add(key);
|
|
||||||
result.push(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add AI SDK messages that aren't duplicates
|
|
||||||
for (const msg of aiSdkMessages) {
|
|
||||||
const key = getMessageKey(msg);
|
|
||||||
if (!seenKeys.has(key)) {
|
|
||||||
seenKeys.add(key);
|
|
||||||
result.push(msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [initialMessages, aiSdkMessages]);
|
|
||||||
|
|
||||||
// Handle initial prompt
|
|
||||||
useEffect(
|
|
||||||
function handleInitialPrompt() {
|
|
||||||
if (!initialPrompt || !sessionId) return;
|
|
||||||
if (initialMessages.length > 0) return;
|
|
||||||
if (hasSentInitialPrompt(sessionId)) return;
|
|
||||||
|
|
||||||
markInitialPromptSent(sessionId);
|
|
||||||
const context = capturePageContext();
|
|
||||||
sendMessageRef.current?.(initialPrompt, true, context);
|
|
||||||
},
|
|
||||||
[initialPrompt, sessionId, initialMessages.length, capturePageContext],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Send message with page context
|
|
||||||
async function sendMessageWithContext(
|
|
||||||
content: string,
|
|
||||||
isUserMessage: boolean = true,
|
|
||||||
) {
|
|
||||||
const context = capturePageContext();
|
|
||||||
await sendMessage(content, isUserMessage, context);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRegionModalOpenChange(open: boolean) {
|
|
||||||
setIsRegionBlockedModalOpen(open);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleRegionModalClose() {
|
|
||||||
setIsRegionBlockedModalOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
messages: filterAuthMessages(allMessages),
|
|
||||||
streamingChunks,
|
|
||||||
isStreaming,
|
|
||||||
error,
|
|
||||||
isRegionBlockedModalOpen,
|
|
||||||
setIsRegionBlockedModalOpen,
|
|
||||||
sendMessageWithContext,
|
|
||||||
handleRegionModalOpenChange,
|
|
||||||
handleRegionModalClose,
|
|
||||||
sendMessage,
|
|
||||||
stopStreaming,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper to generate deduplication key for a message
|
|
||||||
function getMessageKey(msg: ChatMessageData): string {
|
|
||||||
if (msg.type === "message") {
|
|
||||||
return `msg:${msg.role}:${msg.content}`;
|
|
||||||
} else if (msg.type === "tool_call") {
|
|
||||||
return `toolcall:${msg.toolId}`;
|
|
||||||
} else if (msg.type === "tool_response") {
|
|
||||||
return `toolresponse:${(msg as { toolId?: string }).toolId}`;
|
|
||||||
} else if (
|
|
||||||
msg.type === "operation_started" ||
|
|
||||||
msg.type === "operation_pending" ||
|
|
||||||
msg.type === "operation_in_progress"
|
|
||||||
) {
|
|
||||||
const typedMsg = msg as {
|
|
||||||
toolId?: string;
|
|
||||||
operationId?: string;
|
|
||||||
toolCallId?: string;
|
|
||||||
toolName?: string;
|
|
||||||
};
|
|
||||||
return `op:${typedMsg.toolId || typedMsg.operationId || typedMsg.toolCallId || ""}:${typedMsg.toolName || ""}`;
|
|
||||||
} else {
|
|
||||||
return `${msg.type}:${JSON.stringify(msg).slice(0, 100)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -156,11 +156,19 @@ export function ChatMessage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isClarificationNeeded && message.type === "clarification_needed") {
|
if (isClarificationNeeded && message.type === "clarification_needed") {
|
||||||
|
const hasUserReplyAfter =
|
||||||
|
index >= 0 &&
|
||||||
|
messages
|
||||||
|
.slice(index + 1)
|
||||||
|
.some((m) => m.type === "message" && m.role === "user");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ClarificationQuestionsWidget
|
<ClarificationQuestionsWidget
|
||||||
questions={message.questions}
|
questions={message.questions}
|
||||||
message={message.message}
|
message={message.message}
|
||||||
|
sessionId={message.sessionId}
|
||||||
onSubmitAnswers={handleClarificationAnswers}
|
onSubmitAnswers={handleClarificationAnswers}
|
||||||
|
isAnswered={hasUserReplyAfter}
|
||||||
className={className}
|
className={className}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { Input } from "@/components/atoms/Input/Input";
|
|||||||
import { Text } from "@/components/atoms/Text/Text";
|
import { Text } from "@/components/atoms/Text/Text";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { CheckCircleIcon, QuestionIcon } from "@phosphor-icons/react";
|
import { CheckCircleIcon, QuestionIcon } from "@phosphor-icons/react";
|
||||||
import { useState } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
|
|
||||||
export interface ClarifyingQuestion {
|
export interface ClarifyingQuestion {
|
||||||
question: string;
|
question: string;
|
||||||
@@ -17,39 +17,96 @@ export interface ClarifyingQuestion {
|
|||||||
interface Props {
|
interface Props {
|
||||||
questions: ClarifyingQuestion[];
|
questions: ClarifyingQuestion[];
|
||||||
message: string;
|
message: string;
|
||||||
|
sessionId?: string;
|
||||||
onSubmitAnswers: (answers: Record<string, string>) => void;
|
onSubmitAnswers: (answers: Record<string, string>) => void;
|
||||||
onCancel?: () => void;
|
onCancel?: () => void;
|
||||||
|
isAnswered?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStorageKey(sessionId?: string): string | null {
|
||||||
|
if (!sessionId) return null;
|
||||||
|
return `clarification_answers_${sessionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function ClarificationQuestionsWidget({
|
export function ClarificationQuestionsWidget({
|
||||||
questions,
|
questions,
|
||||||
message,
|
message,
|
||||||
|
sessionId,
|
||||||
onSubmitAnswers,
|
onSubmitAnswers,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
isAnswered = false,
|
||||||
className,
|
className,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
const [answers, setAnswers] = useState<Record<string, string>>({});
|
const [answers, setAnswers] = useState<Record<string, string>>({});
|
||||||
const [isSubmitted, setIsSubmitted] = useState(false);
|
const [isSubmitted, setIsSubmitted] = useState(false);
|
||||||
|
const lastSessionIdRef = useRef<string | undefined>(undefined);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const storageKey = getStorageKey(sessionId);
|
||||||
|
if (!storageKey) {
|
||||||
|
setAnswers({});
|
||||||
|
setIsSubmitted(false);
|
||||||
|
lastSessionIdRef.current = sessionId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const saved = localStorage.getItem(storageKey);
|
||||||
|
if (saved) {
|
||||||
|
const parsed = JSON.parse(saved) as Record<string, string>;
|
||||||
|
setAnswers(parsed);
|
||||||
|
} else {
|
||||||
|
setAnswers({});
|
||||||
|
}
|
||||||
|
setIsSubmitted(false);
|
||||||
|
} catch {
|
||||||
|
setAnswers({});
|
||||||
|
setIsSubmitted(false);
|
||||||
|
}
|
||||||
|
lastSessionIdRef.current = sessionId;
|
||||||
|
}, [sessionId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastSessionIdRef.current !== sessionId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const storageKey = getStorageKey(sessionId);
|
||||||
|
if (!storageKey) return;
|
||||||
|
|
||||||
|
const hasAnswers = Object.values(answers).some((v) => v.trim());
|
||||||
|
try {
|
||||||
|
if (hasAnswers) {
|
||||||
|
localStorage.setItem(storageKey, JSON.stringify(answers));
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(storageKey);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
}, [answers, sessionId]);
|
||||||
|
|
||||||
function handleAnswerChange(keyword: string, value: string) {
|
function handleAnswerChange(keyword: string, value: string) {
|
||||||
setAnswers((prev) => ({ ...prev, [keyword]: value }));
|
setAnswers((prev) => ({ ...prev, [keyword]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSubmit() {
|
function handleSubmit() {
|
||||||
// Check if all questions are answered
|
|
||||||
const allAnswered = questions.every((q) => answers[q.keyword]?.trim());
|
const allAnswered = questions.every((q) => answers[q.keyword]?.trim());
|
||||||
if (!allAnswered) {
|
if (!allAnswered) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setIsSubmitted(true);
|
setIsSubmitted(true);
|
||||||
onSubmitAnswers(answers);
|
onSubmitAnswers(answers);
|
||||||
|
|
||||||
|
const storageKey = getStorageKey(sessionId);
|
||||||
|
try {
|
||||||
|
if (storageKey) {
|
||||||
|
localStorage.removeItem(storageKey);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allAnswered = questions.every((q) => answers[q.keyword]?.trim());
|
const allAnswered = questions.every((q) => answers[q.keyword]?.trim());
|
||||||
|
|
||||||
// Show submitted state after answers are submitted
|
if (isAnswered || isSubmitted) {
|
||||||
if (isSubmitted) {
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ export function getErrorMessage(result: unknown): string {
|
|||||||
}
|
}
|
||||||
if (typeof result === "object" && result !== null) {
|
if (typeof result === "object" && result !== null) {
|
||||||
const response = result as Record<string, unknown>;
|
const response = result as Record<string, unknown>;
|
||||||
if (response.error) return stripInternalReasoning(String(response.error));
|
|
||||||
if (response.message)
|
if (response.message)
|
||||||
return stripInternalReasoning(String(response.message));
|
return stripInternalReasoning(String(response.message));
|
||||||
|
if (response.error) return stripInternalReasoning(String(response.error));
|
||||||
}
|
}
|
||||||
return "An error occurred";
|
return "An error occurred";
|
||||||
}
|
}
|
||||||
@@ -363,8 +363,8 @@ export function formatToolResponse(result: unknown, toolName: string): string {
|
|||||||
|
|
||||||
case "error":
|
case "error":
|
||||||
const errorMsg =
|
const errorMsg =
|
||||||
(response.error as string) || response.message || "An error occurred";
|
(response.message as string) || response.error || "An error occurred";
|
||||||
return `Error: ${errorMsg}`;
|
return stripInternalReasoning(String(errorMsg));
|
||||||
|
|
||||||
case "no_results":
|
case "no_results":
|
||||||
const suggestions = (response.suggestions as string[]) || [];
|
const suggestions = (response.suggestions as string[]) || [];
|
||||||
|
|||||||
@@ -1,421 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* useAiSdkChat - Vercel AI SDK integration for CoPilot Chat
|
|
||||||
*
|
|
||||||
* This hook wraps @ai-sdk/react's useChat to provide:
|
|
||||||
* - Streaming chat with the existing Python backend (already AI SDK protocol compatible)
|
|
||||||
* - Integration with existing session management
|
|
||||||
* - Custom tool response parsing for AutoGPT-specific types
|
|
||||||
* - Page context injection
|
|
||||||
*
|
|
||||||
* The Python backend already implements the AI SDK Data Stream Protocol (v1),
|
|
||||||
* so this hook can communicate directly without any backend changes.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useChat as useAiSdkChatBase } from "@ai-sdk/react";
|
|
||||||
import { DefaultChatTransport, type UIMessage } from "ai";
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
import type { ChatMessageData } from "./components/ChatMessage/useChatMessage";
|
|
||||||
|
|
||||||
// Tool response types from the backend
|
|
||||||
type OperationType =
|
|
||||||
| "operation_started"
|
|
||||||
| "operation_pending"
|
|
||||||
| "operation_in_progress";
|
|
||||||
|
|
||||||
interface ToolOutputBase {
|
|
||||||
type: string;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface UseAiSdkChatOptions {
|
|
||||||
sessionId: string | null;
|
|
||||||
initialMessages?: UIMessage[];
|
|
||||||
onOperationStarted?: () => void;
|
|
||||||
onStreamingChange?: (isStreaming: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse tool output from AI SDK message parts into ChatMessageData format
|
|
||||||
*/
|
|
||||||
function parseToolOutput(
|
|
||||||
toolCallId: string,
|
|
||||||
toolName: string,
|
|
||||||
output: unknown,
|
|
||||||
): ChatMessageData | null {
|
|
||||||
if (!output) return null;
|
|
||||||
|
|
||||||
let parsed: ToolOutputBase;
|
|
||||||
try {
|
|
||||||
parsed =
|
|
||||||
typeof output === "string"
|
|
||||||
? JSON.parse(output)
|
|
||||||
: (output as ToolOutputBase);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const type = parsed.type;
|
|
||||||
|
|
||||||
// Handle operation status types
|
|
||||||
if (
|
|
||||||
type === "operation_started" ||
|
|
||||||
type === "operation_pending" ||
|
|
||||||
type === "operation_in_progress"
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
type: type as OperationType,
|
|
||||||
toolId: toolCallId,
|
|
||||||
toolName: toolName,
|
|
||||||
operationId: (parsed.operation_id as string) || undefined,
|
|
||||||
message: (parsed.message as string) || undefined,
|
|
||||||
timestamp: new Date(),
|
|
||||||
} as ChatMessageData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle agent carousel
|
|
||||||
if (type === "agent_carousel" && Array.isArray(parsed.agents)) {
|
|
||||||
return {
|
|
||||||
type: "agent_carousel",
|
|
||||||
toolId: toolCallId,
|
|
||||||
toolName: toolName,
|
|
||||||
agents: parsed.agents,
|
|
||||||
timestamp: new Date(),
|
|
||||||
} as ChatMessageData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle execution started
|
|
||||||
if (type === "execution_started") {
|
|
||||||
return {
|
|
||||||
type: "execution_started",
|
|
||||||
toolId: toolCallId,
|
|
||||||
toolName: toolName,
|
|
||||||
graphId: parsed.graph_id as string,
|
|
||||||
graphVersion: parsed.graph_version as number,
|
|
||||||
graphExecId: parsed.graph_exec_id as string,
|
|
||||||
nodeExecIds: parsed.node_exec_ids as string[],
|
|
||||||
timestamp: new Date(),
|
|
||||||
} as ChatMessageData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle error responses
|
|
||||||
if (type === "error") {
|
|
||||||
return {
|
|
||||||
type: "tool_response",
|
|
||||||
toolId: toolCallId,
|
|
||||||
toolName: toolName,
|
|
||||||
result: parsed,
|
|
||||||
success: false,
|
|
||||||
timestamp: new Date(),
|
|
||||||
} as ChatMessageData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle clarification questions
|
|
||||||
if (type === "clarification_questions" && Array.isArray(parsed.questions)) {
|
|
||||||
return {
|
|
||||||
type: "clarification_questions",
|
|
||||||
toolId: toolCallId,
|
|
||||||
toolName: toolName,
|
|
||||||
questions: parsed.questions,
|
|
||||||
timestamp: new Date(),
|
|
||||||
} as ChatMessageData;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle credentials needed
|
|
||||||
if (type === "credentials_needed" || type === "setup_requirements") {
|
|
||||||
const credentials = parsed.credentials as
|
|
||||||
| Array<{
|
|
||||||
provider: string;
|
|
||||||
provider_name: string;
|
|
||||||
credential_type: string;
|
|
||||||
scopes?: string[];
|
|
||||||
}>
|
|
||||||
| undefined;
|
|
||||||
if (credentials && credentials.length > 0) {
|
|
||||||
return {
|
|
||||||
type: "credentials_needed",
|
|
||||||
toolId: toolCallId,
|
|
||||||
toolName: toolName,
|
|
||||||
credentials: credentials,
|
|
||||||
timestamp: new Date(),
|
|
||||||
} as ChatMessageData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: generic tool response
|
|
||||||
return {
|
|
||||||
type: "tool_response",
|
|
||||||
toolId: toolCallId,
|
|
||||||
toolName: toolName,
|
|
||||||
result: parsed,
|
|
||||||
success: true,
|
|
||||||
timestamp: new Date(),
|
|
||||||
} as ChatMessageData;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert AI SDK UIMessage parts to ChatMessageData array
|
|
||||||
*/
|
|
||||||
function convertMessageToChatData(message: UIMessage): ChatMessageData[] {
|
|
||||||
const result: ChatMessageData[] = [];
|
|
||||||
|
|
||||||
for (const part of message.parts) {
|
|
||||||
switch (part.type) {
|
|
||||||
case "text":
|
|
||||||
if (part.text.trim()) {
|
|
||||||
result.push({
|
|
||||||
type: "message",
|
|
||||||
role: message.role as "user" | "assistant",
|
|
||||||
content: part.text,
|
|
||||||
timestamp: new Date(message.createdAt || Date.now()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
default:
|
|
||||||
// Handle tool parts (tool-*)
|
|
||||||
if (part.type.startsWith("tool-")) {
|
|
||||||
const toolPart = part as {
|
|
||||||
type: string;
|
|
||||||
toolCallId: string;
|
|
||||||
toolName: string;
|
|
||||||
state: string;
|
|
||||||
input?: Record<string, unknown>;
|
|
||||||
output?: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show tool call in progress
|
|
||||||
if (
|
|
||||||
toolPart.state === "input-streaming" ||
|
|
||||||
toolPart.state === "input-available"
|
|
||||||
) {
|
|
||||||
result.push({
|
|
||||||
type: "tool_call",
|
|
||||||
toolId: toolPart.toolCallId,
|
|
||||||
toolName: toolPart.toolName,
|
|
||||||
arguments: toolPart.input || {},
|
|
||||||
timestamp: new Date(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse tool output when available
|
|
||||||
if (
|
|
||||||
toolPart.state === "output-available" &&
|
|
||||||
toolPart.output !== undefined
|
|
||||||
) {
|
|
||||||
const parsed = parseToolOutput(
|
|
||||||
toolPart.toolCallId,
|
|
||||||
toolPart.toolName,
|
|
||||||
toolPart.output,
|
|
||||||
);
|
|
||||||
if (parsed) {
|
|
||||||
result.push(parsed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle tool errors
|
|
||||||
if (toolPart.state === "output-error") {
|
|
||||||
result.push({
|
|
||||||
type: "tool_response",
|
|
||||||
toolId: toolPart.toolCallId,
|
|
||||||
toolName: toolPart.toolName,
|
|
||||||
response: {
|
|
||||||
type: "error",
|
|
||||||
message: (toolPart as { errorText?: string }).errorText,
|
|
||||||
},
|
|
||||||
success: false,
|
|
||||||
timestamp: new Date(),
|
|
||||||
} as ChatMessageData);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAiSdkChat({
|
|
||||||
sessionId,
|
|
||||||
initialMessages = [],
|
|
||||||
onOperationStarted,
|
|
||||||
onStreamingChange,
|
|
||||||
}: UseAiSdkChatOptions) {
|
|
||||||
const [isRegionBlockedModalOpen, setIsRegionBlockedModalOpen] =
|
|
||||||
useState(false);
|
|
||||||
const previousSessionIdRef = useRef<string | null>(null);
|
|
||||||
const hasNotifiedOperationRef = useRef<Set<string>>(new Set());
|
|
||||||
|
|
||||||
// Create transport with session-specific endpoint
|
|
||||||
const transport = useMemo(() => {
|
|
||||||
if (!sessionId) return undefined;
|
|
||||||
return new DefaultChatTransport({
|
|
||||||
api: `/api/chat/sessions/${sessionId}/stream`,
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}, [sessionId]);
|
|
||||||
|
|
||||||
const {
|
|
||||||
messages: aiMessages,
|
|
||||||
status,
|
|
||||||
error,
|
|
||||||
stop,
|
|
||||||
setMessages,
|
|
||||||
sendMessage: aiSendMessage,
|
|
||||||
} = useAiSdkChatBase({
|
|
||||||
transport,
|
|
||||||
initialMessages,
|
|
||||||
onError: (err) => {
|
|
||||||
console.error("[useAiSdkChat] Error:", err);
|
|
||||||
|
|
||||||
// Check for region blocking
|
|
||||||
if (
|
|
||||||
err.message?.toLowerCase().includes("not available in your region") ||
|
|
||||||
(err as { code?: string }).code === "MODEL_NOT_AVAILABLE_REGION"
|
|
||||||
) {
|
|
||||||
setIsRegionBlockedModalOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
toast.error("Chat Error", {
|
|
||||||
description: err.message || "An error occurred",
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onFinish: ({ message }) => {
|
|
||||||
console.info("[useAiSdkChat] Message finished:", {
|
|
||||||
id: message.id,
|
|
||||||
partsCount: message.parts.length,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// Track streaming status
|
|
||||||
const isStreaming = status === "streaming" || status === "submitted";
|
|
||||||
|
|
||||||
// Notify parent of streaming changes
|
|
||||||
useEffect(() => {
|
|
||||||
onStreamingChange?.(isStreaming);
|
|
||||||
}, [isStreaming, onStreamingChange]);
|
|
||||||
|
|
||||||
// Handle session changes - reset state
|
|
||||||
useEffect(() => {
|
|
||||||
if (sessionId === previousSessionIdRef.current) return;
|
|
||||||
|
|
||||||
if (previousSessionIdRef.current && status === "streaming") {
|
|
||||||
stop();
|
|
||||||
}
|
|
||||||
|
|
||||||
previousSessionIdRef.current = sessionId;
|
|
||||||
hasNotifiedOperationRef.current = new Set();
|
|
||||||
|
|
||||||
if (sessionId) {
|
|
||||||
setMessages(initialMessages);
|
|
||||||
}
|
|
||||||
}, [sessionId, status, stop, setMessages, initialMessages]);
|
|
||||||
|
|
||||||
// Convert AI SDK messages to ChatMessageData format
|
|
||||||
const messages = useMemo(() => {
|
|
||||||
const result: ChatMessageData[] = [];
|
|
||||||
|
|
||||||
for (const message of aiMessages) {
|
|
||||||
const converted = convertMessageToChatData(message);
|
|
||||||
result.push(...converted);
|
|
||||||
|
|
||||||
// Check for operation_started and notify
|
|
||||||
for (const msg of converted) {
|
|
||||||
if (
|
|
||||||
msg.type === "operation_started" &&
|
|
||||||
!hasNotifiedOperationRef.current.has(
|
|
||||||
(msg as { toolId?: string }).toolId || "",
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
hasNotifiedOperationRef.current.add(
|
|
||||||
(msg as { toolId?: string }).toolId || "",
|
|
||||||
);
|
|
||||||
onOperationStarted?.();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [aiMessages, onOperationStarted]);
|
|
||||||
|
|
||||||
// Get streaming text chunks from the last assistant message
|
|
||||||
const streamingChunks = useMemo(() => {
|
|
||||||
if (!isStreaming) return [];
|
|
||||||
|
|
||||||
const lastMessage = aiMessages[aiMessages.length - 1];
|
|
||||||
if (!lastMessage || lastMessage.role !== "assistant") return [];
|
|
||||||
|
|
||||||
const chunks: string[] = [];
|
|
||||||
for (const part of lastMessage.parts) {
|
|
||||||
if (part.type === "text" && part.text) {
|
|
||||||
chunks.push(part.text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return chunks;
|
|
||||||
}, [aiMessages, isStreaming]);
|
|
||||||
|
|
||||||
// Send message with optional context
|
|
||||||
const sendMessage = useCallback(
|
|
||||||
async (
|
|
||||||
content: string,
|
|
||||||
isUserMessage: boolean = true,
|
|
||||||
context?: { url: string; content: string },
|
|
||||||
) => {
|
|
||||||
if (!sessionId || !transport) {
|
|
||||||
console.error("[useAiSdkChat] Cannot send message: no session");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setIsRegionBlockedModalOpen(false);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await aiSendMessage(
|
|
||||||
{ text: content },
|
|
||||||
{
|
|
||||||
body: {
|
|
||||||
is_user_message: isUserMessage,
|
|
||||||
context: context || null,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
console.error("[useAiSdkChat] Failed to send message:", err);
|
|
||||||
|
|
||||||
if (err instanceof Error && err.name === "AbortError") return;
|
|
||||||
|
|
||||||
toast.error("Failed to send message", {
|
|
||||||
description:
|
|
||||||
err instanceof Error ? err.message : "Failed to send message",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[sessionId, transport, aiSendMessage],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Stop streaming
|
|
||||||
const stopStreaming = useCallback(() => {
|
|
||||||
stop();
|
|
||||||
}, [stop]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
messages,
|
|
||||||
streamingChunks,
|
|
||||||
isStreaming,
|
|
||||||
error,
|
|
||||||
status,
|
|
||||||
isRegionBlockedModalOpen,
|
|
||||||
setIsRegionBlockedModalOpen,
|
|
||||||
sendMessage,
|
|
||||||
stopStreaming,
|
|
||||||
// Expose raw AI SDK state for advanced use cases
|
|
||||||
aiMessages,
|
|
||||||
setAiMessages: setMessages,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,28 @@
|
|||||||
|
|
||||||
This guide walks through creating a simple question-answer AI agent using AutoGPT's visual builder. This is a basic example that can be expanded into more complex agents.
|
This guide walks through creating a simple question-answer AI agent using AutoGPT's visual builder. This is a basic example that can be expanded into more complex agents.
|
||||||
|
|
||||||
|
## **Prerequisites**
|
||||||
|
|
||||||
|
### **Cloud-Hosted AutoGPT**
|
||||||
|
If you're using the cloud-hosted version at [agpt.co](https://agpt.co), you're ready to go! AI blocks come with **built-in credits** — no API keys required to get started. If you'd prefer to use your own API keys, you can add them via **Profile → Integrations**.
|
||||||
|
|
||||||
|
### **Self-Hosted (Docker)**
|
||||||
|
If you're running AutoGPT locally with Docker, you'll need to add your own API keys to `autogpt_platform/backend/.env`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create or edit backend/.env
|
||||||
|
OPENAI_API_KEY=sk-your-key-here
|
||||||
|
ANTHROPIC_API_KEY=sk-ant-your-key-here
|
||||||
|
# Add other provider keys as needed
|
||||||
|
```
|
||||||
|
|
||||||
|
After adding keys, restart the services:
|
||||||
|
```bash
|
||||||
|
docker compose down && docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note:** The Calculator example below doesn't require any API credentials — it's a good way to test your setup before adding AI blocks.
|
||||||
|
|
||||||
## **Example Agent: Q&A (with AI)**
|
## **Example Agent: Q&A (with AI)**
|
||||||
|
|
||||||
A step-by-step guide to creating a simple Q&A agent using input and output blocks.
|
A step-by-step guide to creating a simple Q&A agent using input and output blocks.
|
||||||
|
|||||||
Reference in New Issue
Block a user