Compare commits

..

30 Commits

Author SHA1 Message Date
Swifty
8f868bb246 Merge branch 'dev' into hackathon-copilot-backend 2026-01-13 14:28:21 +01:00
Swifty
5e6d5f97e5 switch to vercel ai sdk protocol 2026-01-13 14:27:08 +01:00
Bently
e539280e98 fix(blocks): set User-Agent header and URL-encode topic in GetWikipediaSummaryBlock (#11754)
The GetWikipediaSummaryBlock was returning HTTP 403 errors from
Wikipedia's API because it wasn't explicitly setting a User-Agent header
that complies with https://wikitech.wikimedia.org/wiki/Robot_policy.
Additionally, topics with spaces or special characters would cause
malformed URLs.

Fixes: OPEN-2889

Changes 🏗️

- URL-encode the topic parameter using urllib.parse.quote() to handle
spaces and special characters
- Explicitly set required headers per Wikimedia robot policy:
- User-Agent: Platform default user agent (includes app name, URL, and
contact email)
- Accept-Encoding: gzip, deflate: Recommended by Wikimedia to reduce
bandwidth
- Updated test mock to match the new function signature

Checklist 📋

For code changes:

- [x] I have clearly listed my changes in the PR description
- [x] I have made a test plan
- [x] I have tested my changes according to the test plan:
  - [x] Verify code passes syntax check
  - [x] Verify code passes ruff linting
- [x] Create an agent using GetWikipediaSummaryBlock with a topic
containing spaces (e.g., "Artificial Intelligence")
  - [x] Verify the block returns a Wikipedia summary without 403 errors

For configuration changes:

- .env.default is updated or already compatible with my changes
- docker-compose.yml is updated or already compatible with my changes
- I have included a list of my configuration changes in the PR
description (under Changes)
.
N/A - No configuration changes required.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **Bug Fixes**
* Improved Wikipedia API requests by adding compatible request headers
(including a proper user agent and encoding acceptance) for more
reliable responses.
* Enhanced handling of search topics by URL-encoding terms so queries
with spaces or special characters return correct results.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-01-13 12:24:51 +00:00
Swifty
8fa972d26e add model logging in update too 2026-01-13 12:38:27 +01:00
Swifty
5ea7611b60 update to langfuse v3 sdk 2026-01-13 12:08:01 +01:00
Swifty
b590288955 fix(backend): improve chat session reliability and security
- Add transaction wrapper to add_chat_messages_batch for atomicity
- Fix race condition in upsert_chat_session with session-level locking
- Fix asymmetric error handling - propagate DB errors as DatabaseError
- Add user_id validation to delete_chat_session to prevent unauthorized deletion
- Fix stale session capture in _update_title background task
- Add update_session_title function for lightweight title-only updates
- Handle invalid date components in parse_time_expression
- Fix Langfuse trace typing issue
2026-01-13 11:49:23 +01:00
Swifty
09183cce39 update langfuse to latest 2026-01-13 11:38:54 +01:00
Swifty
da2f0876e8 update openapi spec 2026-01-13 10:35:28 +01:00
Swifty
b9726dd4ab Merge branch 'dev' into hackathon-copilot-backend 2026-01-13 10:34:33 +01:00
Swifty
116b0c388b add langfuse tracing 2026-01-13 10:33:41 +01:00
Swifty
cf4b36a6cc switching to using langfuse prompt management 2026-01-13 09:41:37 +01:00
Torantulino
7a73a4c85d feat(langfuse): integrate Langfuse for prompt management
- Added Langfuse configuration to settings and environment variables.
- Introduced Langfuse client for fetching prompts in chat service.
- Updated ChatConfig to include Langfuse prompt name.
- Enhanced service logic to retrieve prompts from Langfuse, improving prompt management capabilities.

This integration allows for rapid runtime prompt updates and eventual analytics of the performance of the Co-Pilot system.

This commit aims to trial this service as a potential option.
2026-01-13 09:07:24 +01:00
Swifty
9efae07c7b make it clear we are adding the uesrs name 2026-01-13 09:05:53 +01:00
Swifty
28e997c2ee simplify implementation 2026-01-12 18:01:10 +01:00
Swifty
f8192e29c2 Merge branch 'dev' into hackathon-copilot-backend 2026-01-12 11:28:09 +01:00
Swifty
b46b9c7b97 Merge branch 'dev' into hackathon-copilot-backend 2026-01-09 16:31:39 +01:00
Swifty
08c3f19fe4 Merge branch 'dev' into hackathon-copilot-backend 2026-01-08 12:53:40 +01:00
Swifty
a23f766ed7 Merge branch 'dev' into hackathon-copilot-backend 2026-01-08 12:44:58 +01:00
Swifty
e6a5631e47 update migration 2026-01-08 12:10:55 +01:00
Swifty
1a7e5d3caf Merge branch 'dev' into hackathon-copilot-backend 2026-01-08 10:44:25 +01:00
Swifty
acd702ff55 update openapi.json 2026-01-08 10:44:13 +01:00
Swifty
cb89669ee1 Merge branch 'hackathon-copilot-backend' of github.com:Significant-Gravitas/AutoGPT into hackathon-copilot-backend 2026-01-08 10:43:21 +01:00
Swifty
eee478fa31 fix tests 2026-01-08 10:43:15 +01:00
Swifty
8a6bdaefe7 Merge branch 'dev' into hackathon-copilot-backend 2026-01-08 10:04:16 +01:00
Swifty
fd768e8f3f fixing linting errors 2026-01-08 09:40:54 +01:00
Swifty
21626edfa7 Merge branch 'dev' into hackathon-copilot-backend 2026-01-07 16:42:04 +01:00
Swifty
e6cd198e4d remove tool imports from tools we have not added yet 2026-01-07 15:49:37 +01:00
Swifty
e17d2a71db Merge branch 'dev' into hackathon-copilot-backend 2026-01-07 11:34:04 +01:00
Swifty
e8b878521f added prisma schame changes and understanding tool 2026-01-07 10:02:44 +01:00
Swifty
d3dd13fc55 extracted core chat changes from hackathon/copilot 2026-01-07 09:40:13 +01:00
70 changed files with 3581 additions and 2310 deletions

View File

@@ -0,0 +1,9 @@
{
"permissions": {
"allow": [
"Bash(ls:*)",
"WebFetch(domain:langfuse.com)",
"Bash(poetry install:*)"
]
}
}

View File

@@ -6,9 +6,10 @@ start-core:
# Stop core services
stop-core:
docker compose stop deps
docker compose stop
reset-db:
docker compose stop db
rm -rf db/docker/volumes/db/data
cd backend && poetry run prisma migrate deploy
cd backend && poetry run prisma generate
@@ -60,4 +61,4 @@ help:
@echo " run-backend - Run the backend FastAPI server"
@echo " run-frontend - Run the frontend Next.js development server"
@echo " test-data - Run the test data creator"
@echo " load-store-agents - Load store agents from agents/ folder into test database"
@echo " load-store-agents - Load store agents from agents/ folder into test database"

View File

@@ -58,6 +58,13 @@ V0_API_KEY=
OPEN_ROUTER_API_KEY=
NVIDIA_API_KEY=
# Langfuse Prompt Management
# Used for managing the CoPilot system prompt externally
# Get credentials from https://cloud.langfuse.com or your self-hosted instance
LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=
LANGFUSE_HOST=https://cloud.langfuse.com
# OAuth Credentials
# For the OAuth callback URL, use <your_frontend_url>/auth/integrations/oauth_callback,
# e.g. http://localhost:3000/auth/integrations/oauth_callback

View File

@@ -1,7 +1,6 @@
"""Configuration management for chat system."""
import os
from pathlib import Path
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings
@@ -12,7 +11,11 @@ class ChatConfig(BaseSettings):
# OpenAI API Configuration
model: str = Field(
default="qwen/qwen3-235b-a22b-2507", description="Default model to use"
default="anthropic/claude-opus-4.5", description="Default model to use"
)
title_model: str = Field(
default="openai/gpt-4o-mini",
description="Model to use for generating session titles (should be fast/cheap)",
)
api_key: str | None = Field(default=None, description="OpenAI API key")
base_url: str | None = Field(
@@ -23,12 +26,6 @@ class ChatConfig(BaseSettings):
# Session TTL Configuration - 12 hours
session_ttl: int = Field(default=43200, description="Session TTL in seconds")
# System Prompt Configuration
system_prompt_path: str = Field(
default="prompts/chat_system.md",
description="Path to system prompt file relative to chat module",
)
# Streaming Configuration
max_context_messages: int = Field(
default=50, ge=1, le=200, description="Maximum context messages"
@@ -41,6 +38,13 @@ class ChatConfig(BaseSettings):
default=3, description="Maximum number of agent schedules"
)
# Langfuse Prompt Management Configuration
# Note: Langfuse credentials are in Settings().secrets (settings.py)
langfuse_prompt_name: str = Field(
default="CoPilot Prompt",
description="Name of the prompt in Langfuse to fetch",
)
@field_validator("api_key", mode="before")
@classmethod
def get_api_key(cls, v):
@@ -72,43 +76,11 @@ class ChatConfig(BaseSettings):
v = "https://openrouter.ai/api/v1"
return v
def get_system_prompt(self, **template_vars) -> str:
"""Load and render the system prompt from file.
Args:
**template_vars: Variables to substitute in the template
Returns:
Rendered system prompt string
"""
# Get the path relative to this module
module_dir = Path(__file__).parent
prompt_path = module_dir / self.system_prompt_path
# Check for .j2 extension first (Jinja2 template)
j2_path = Path(str(prompt_path) + ".j2")
if j2_path.exists():
try:
from jinja2 import Template
template = Template(j2_path.read_text())
return template.render(**template_vars)
except ImportError:
# Jinja2 not installed, fall back to reading as plain text
return j2_path.read_text()
# Check for markdown file
if prompt_path.exists():
content = prompt_path.read_text()
# Simple variable substitution if Jinja2 is not available
for key, value in template_vars.items():
placeholder = f"{{{key}}}"
content = content.replace(placeholder, str(value))
return content
raise FileNotFoundError(f"System prompt file not found: {prompt_path}")
# Prompt paths for different contexts
PROMPT_PATHS: dict[str, str] = {
"default": "prompts/chat_system.md",
"onboarding": "prompts/onboarding_system.md",
}
class Config:
"""Pydantic config."""

View File

@@ -0,0 +1,243 @@
"""Database operations for chat sessions."""
import logging
from datetime import UTC, datetime
from typing import Any, cast
from prisma.models import ChatMessage as PrismaChatMessage
from prisma.models import ChatSession as PrismaChatSession
from prisma.types import (
ChatMessageCreateInput,
ChatSessionCreateInput,
ChatSessionUpdateInput,
)
from backend.data.db import transaction
from backend.util.json import SafeJson
logger = logging.getLogger(__name__)
async def get_chat_session(session_id: str) -> PrismaChatSession | None:
"""Get a chat session by ID from the database."""
session = await PrismaChatSession.prisma().find_unique(
where={"id": session_id},
include={"Messages": True},
)
if session and session.Messages:
# Sort messages by sequence in Python since Prisma doesn't support order_by in include
session.Messages.sort(key=lambda m: m.sequence)
return session
async def create_chat_session(
session_id: str,
user_id: str | None,
) -> PrismaChatSession:
"""Create a new chat session in the database."""
data = ChatSessionCreateInput(
id=session_id,
userId=user_id,
credentials=SafeJson({}),
successfulAgentRuns=SafeJson({}),
successfulAgentSchedules=SafeJson({}),
)
return await PrismaChatSession.prisma().create(
data=data,
include={"Messages": True},
)
async def update_chat_session(
session_id: str,
credentials: dict[str, Any] | None = None,
successful_agent_runs: dict[str, Any] | None = None,
successful_agent_schedules: dict[str, Any] | None = None,
total_prompt_tokens: int | None = None,
total_completion_tokens: int | None = None,
title: str | None = None,
) -> PrismaChatSession | None:
"""Update a chat session's metadata."""
data: ChatSessionUpdateInput = {"updatedAt": datetime.now(UTC)}
if credentials is not None:
data["credentials"] = SafeJson(credentials)
if successful_agent_runs is not None:
data["successfulAgentRuns"] = SafeJson(successful_agent_runs)
if successful_agent_schedules is not None:
data["successfulAgentSchedules"] = SafeJson(successful_agent_schedules)
if total_prompt_tokens is not None:
data["totalPromptTokens"] = total_prompt_tokens
if total_completion_tokens is not None:
data["totalCompletionTokens"] = total_completion_tokens
if title is not None:
data["title"] = title
session = await PrismaChatSession.prisma().update(
where={"id": session_id},
data=data,
include={"Messages": True},
)
if session and session.Messages:
session.Messages.sort(key=lambda m: m.sequence)
return session
async def add_chat_message(
session_id: str,
role: str,
sequence: int,
content: str | None = None,
name: str | None = None,
tool_call_id: str | None = None,
refusal: str | None = None,
tool_calls: list[dict[str, Any]] | None = None,
function_call: dict[str, Any] | None = None,
) -> PrismaChatMessage:
"""Add a message to a chat session."""
# Build the input dict dynamically - only include optional fields when they
# have values, as Prisma TypedDict validation fails when optional fields
# are explicitly set to None
data: dict[str, Any] = {
"Session": {"connect": {"id": session_id}},
"role": role,
"sequence": sequence,
}
# Add optional string fields
if content is not None:
data["content"] = content
if name is not None:
data["name"] = name
if tool_call_id is not None:
data["toolCallId"] = tool_call_id
if refusal is not None:
data["refusal"] = refusal
# Add optional JSON fields only when they have values
if tool_calls is not None:
data["toolCalls"] = SafeJson(tool_calls)
if function_call is not None:
data["functionCall"] = SafeJson(function_call)
# Update session's updatedAt timestamp
await PrismaChatSession.prisma().update(
where={"id": session_id},
data={"updatedAt": datetime.now(UTC)},
)
return await PrismaChatMessage.prisma().create(
data=cast(ChatMessageCreateInput, data)
)
async def add_chat_messages_batch(
session_id: str,
messages: list[dict[str, Any]],
start_sequence: int,
) -> list[PrismaChatMessage]:
"""Add multiple messages to a chat session in a batch.
Uses a transaction for atomicity - if any message creation fails,
the entire batch is rolled back.
"""
if not messages:
return []
created_messages = []
async with transaction() as tx:
for i, msg in enumerate(messages):
# Build the input dict dynamically - only include optional JSON fields
# when they have values, as Prisma TypedDict validation fails when
# optional fields are explicitly set to None
data: dict[str, Any] = {
"Session": {"connect": {"id": session_id}},
"role": msg["role"],
"sequence": start_sequence + i,
}
# Add optional string fields
if msg.get("content") is not None:
data["content"] = msg["content"]
if msg.get("name") is not None:
data["name"] = msg["name"]
if msg.get("tool_call_id") is not None:
data["toolCallId"] = msg["tool_call_id"]
if msg.get("refusal") is not None:
data["refusal"] = msg["refusal"]
# Add optional JSON fields only when they have values
if msg.get("tool_calls") is not None:
data["toolCalls"] = SafeJson(msg["tool_calls"])
if msg.get("function_call") is not None:
data["functionCall"] = SafeJson(msg["function_call"])
created = await PrismaChatMessage.prisma(tx).create(
data=cast(ChatMessageCreateInput, data)
)
created_messages.append(created)
# Update session's updatedAt timestamp within the same transaction
await PrismaChatSession.prisma(tx).update(
where={"id": session_id},
data={"updatedAt": datetime.now(UTC)},
)
return created_messages
async def get_user_chat_sessions(
user_id: str,
limit: int = 50,
offset: int = 0,
) -> list[PrismaChatSession]:
"""Get chat sessions for a user, ordered by most recent."""
return await PrismaChatSession.prisma().find_many(
where={"userId": user_id},
order={"updatedAt": "desc"},
take=limit,
skip=offset,
)
async def get_user_session_count(user_id: str) -> int:
"""Get the total number of chat sessions for a user."""
return await PrismaChatSession.prisma().count(where={"userId": user_id})
async def delete_chat_session(session_id: str, user_id: str | None = None) -> bool:
"""Delete a chat session and all its messages.
Args:
session_id: The session ID to delete.
user_id: If provided, validates that the session belongs to this user
before deletion. This prevents unauthorized deletion of other
users' sessions.
Returns:
True if deleted successfully, False otherwise.
"""
try:
# Build where clause with optional user_id validation
where_clause: dict[str, Any] = {"id": session_id}
if user_id is not None:
where_clause["userId"] = user_id
result = await PrismaChatSession.prisma().delete_many(where=where_clause)
if result == 0:
logger.warning(
f"No session deleted for {session_id} "
f"(user_id validation: {user_id is not None})"
)
return False
return True
except Exception as e:
logger.error(f"Failed to delete chat session {session_id}: {e}")
return False
async def get_chat_session_message_count(session_id: str) -> int:
"""Get the number of messages in a chat session."""
count = await PrismaChatMessage.prisma().count(where={"sessionId": session_id})
return count

View File

@@ -1,3 +1,4 @@
import asyncio
import logging
import uuid
from datetime import UTC, datetime
@@ -16,16 +17,32 @@ from openai.types.chat.chat_completion_message_tool_call_param import (
ChatCompletionMessageToolCallParam,
Function,
)
from prisma.models import ChatMessage as PrismaChatMessage
from prisma.models import ChatSession as PrismaChatSession
from pydantic import BaseModel
from backend.data.redis_client import get_redis_async
from backend.util.exceptions import RedisError
from backend.util import json
from backend.util.exceptions import DatabaseError, RedisError
from . import db as chat_db
from .config import ChatConfig
logger = logging.getLogger(__name__)
config = ChatConfig()
# Session-level locks to prevent race conditions during concurrent upserts
_session_locks: dict[str, asyncio.Lock] = {}
_session_locks_mutex = asyncio.Lock()
async def _get_session_lock(session_id: str) -> asyncio.Lock:
"""Get or create a lock for a specific session to prevent concurrent upserts."""
async with _session_locks_mutex:
if session_id not in _session_locks:
_session_locks[session_id] = asyncio.Lock()
return _session_locks[session_id]
class ChatMessage(BaseModel):
role: str
@@ -46,6 +63,7 @@ class Usage(BaseModel):
class ChatSession(BaseModel):
session_id: str
user_id: str | None
title: str | None = None
messages: list[ChatMessage]
usage: list[Usage]
credentials: dict[str, dict] = {} # Map of provider -> credential metadata
@@ -59,6 +77,7 @@ class ChatSession(BaseModel):
return ChatSession(
session_id=str(uuid.uuid4()),
user_id=user_id,
title=None,
messages=[],
usage=[],
credentials={},
@@ -66,6 +85,85 @@ class ChatSession(BaseModel):
updated_at=datetime.now(UTC),
)
@staticmethod
def from_prisma(
prisma_session: PrismaChatSession,
prisma_messages: list[PrismaChatMessage] | None = None,
) -> "ChatSession":
"""Convert Prisma models to Pydantic ChatSession."""
messages = []
if prisma_messages:
for msg in prisma_messages:
tool_calls = None
if msg.toolCalls:
tool_calls = (
json.loads(msg.toolCalls)
if isinstance(msg.toolCalls, str)
else msg.toolCalls
)
function_call = None
if msg.functionCall:
function_call = (
json.loads(msg.functionCall)
if isinstance(msg.functionCall, str)
else msg.functionCall
)
messages.append(
ChatMessage(
role=msg.role,
content=msg.content,
name=msg.name,
tool_call_id=msg.toolCallId,
refusal=msg.refusal,
tool_calls=tool_calls,
function_call=function_call,
)
)
# Parse JSON fields from Prisma
credentials = (
json.loads(prisma_session.credentials)
if isinstance(prisma_session.credentials, str)
else prisma_session.credentials or {}
)
successful_agent_runs = (
json.loads(prisma_session.successfulAgentRuns)
if isinstance(prisma_session.successfulAgentRuns, str)
else prisma_session.successfulAgentRuns or {}
)
successful_agent_schedules = (
json.loads(prisma_session.successfulAgentSchedules)
if isinstance(prisma_session.successfulAgentSchedules, str)
else prisma_session.successfulAgentSchedules or {}
)
# Calculate usage from token counts
usage = []
if prisma_session.totalPromptTokens or prisma_session.totalCompletionTokens:
usage.append(
Usage(
prompt_tokens=prisma_session.totalPromptTokens or 0,
completion_tokens=prisma_session.totalCompletionTokens or 0,
total_tokens=(prisma_session.totalPromptTokens or 0)
+ (prisma_session.totalCompletionTokens or 0),
)
)
return ChatSession(
session_id=prisma_session.id,
user_id=prisma_session.userId,
title=prisma_session.title,
messages=messages,
usage=usage,
credentials=credentials,
started_at=prisma_session.createdAt,
updated_at=prisma_session.updatedAt,
successful_agent_runs=successful_agent_runs,
successful_agent_schedules=successful_agent_schedules,
)
def to_openai_messages(self) -> list[ChatCompletionMessageParam]:
messages = []
for message in self.messages:
@@ -155,50 +253,308 @@ class ChatSession(BaseModel):
return messages
async def get_chat_session(
session_id: str,
user_id: str | None,
) -> ChatSession | None:
"""Get a chat session by ID."""
async def _get_session_from_cache(session_id: str) -> ChatSession | None:
"""Get a chat session from Redis cache."""
redis_key = f"chat:session:{session_id}"
async_redis = await get_redis_async()
raw_session: bytes | None = await async_redis.get(redis_key)
if raw_session is None:
logger.warning(f"Session {session_id} not found in Redis")
return None
try:
session = ChatSession.model_validate_json(raw_session)
logger.info(
f"Loading session {session_id} from cache: "
f"message_count={len(session.messages)}, "
f"roles={[m.role for m in session.messages]}"
)
return session
except Exception as e:
logger.error(f"Failed to deserialize session {session_id}: {e}", exc_info=True)
raise RedisError(f"Corrupted session data for {session_id}") from e
async def _cache_session(session: ChatSession) -> None:
"""Cache a chat session in Redis."""
redis_key = f"chat:session:{session.session_id}"
async_redis = await get_redis_async()
await async_redis.setex(redis_key, config.session_ttl, session.model_dump_json())
async def _get_session_from_db(session_id: str) -> ChatSession | None:
"""Get a chat session from the database."""
prisma_session = await chat_db.get_chat_session(session_id)
if not prisma_session:
return None
messages = prisma_session.Messages
logger.info(
f"Loading session {session_id} from DB: "
f"has_messages={messages is not None}, "
f"message_count={len(messages) if messages else 0}, "
f"roles={[m.role for m in messages] if messages else []}"
)
return ChatSession.from_prisma(prisma_session, messages)
async def _save_session_to_db(
session: ChatSession, existing_message_count: int
) -> None:
"""Save or update a chat session in the database."""
# Check if session exists in DB
existing = await chat_db.get_chat_session(session.session_id)
if not existing:
# Create new session
await chat_db.create_chat_session(
session_id=session.session_id,
user_id=session.user_id,
)
existing_message_count = 0
# Calculate total tokens from usage
total_prompt = sum(u.prompt_tokens for u in session.usage)
total_completion = sum(u.completion_tokens for u in session.usage)
# Update session metadata
await chat_db.update_chat_session(
session_id=session.session_id,
credentials=session.credentials,
successful_agent_runs=session.successful_agent_runs,
successful_agent_schedules=session.successful_agent_schedules,
total_prompt_tokens=total_prompt,
total_completion_tokens=total_completion,
)
# Add new messages (only those after existing count)
new_messages = session.messages[existing_message_count:]
if new_messages:
messages_data = []
for msg in new_messages:
messages_data.append(
{
"role": msg.role,
"content": msg.content,
"name": msg.name,
"tool_call_id": msg.tool_call_id,
"refusal": msg.refusal,
"tool_calls": msg.tool_calls,
"function_call": msg.function_call,
}
)
logger.info(
f"Saving {len(new_messages)} new messages to DB for session {session.session_id}: "
f"roles={[m['role'] for m in messages_data]}, "
f"start_sequence={existing_message_count}"
)
await chat_db.add_chat_messages_batch(
session_id=session.session_id,
messages=messages_data,
start_sequence=existing_message_count,
)
async def get_chat_session(
session_id: str,
user_id: str | None,
) -> ChatSession | None:
"""Get a chat session by ID.
Checks Redis cache first, falls back to database if not found.
Caches database results back to Redis.
"""
# Try cache first
try:
session = await _get_session_from_cache(session_id)
if session:
# Verify user ownership
if session.user_id is not None and session.user_id != user_id:
logger.warning(
f"Session {session_id} user id mismatch: {session.user_id} != {user_id}"
)
return None
return session
except RedisError:
logger.warning(f"Cache error for session {session_id}, trying database")
except Exception as e:
logger.warning(f"Unexpected cache error for session {session_id}: {e}")
# Fall back to database
logger.info(f"Session {session_id} not in cache, checking database")
session = await _get_session_from_db(session_id)
if session is None:
logger.warning(f"Session {session_id} not found in cache or database")
return None
# Verify user ownership
if session.user_id is not None and session.user_id != user_id:
logger.warning(
f"Session {session_id} user id mismatch: {session.user_id} != {user_id}"
)
return None
# Cache the session from DB
try:
await _cache_session(session)
logger.info(f"Cached session {session_id} from database")
except Exception as e:
logger.warning(f"Failed to cache session {session_id}: {e}")
return session
async def upsert_chat_session(
session: ChatSession,
) -> ChatSession:
"""Update a chat session with the given messages."""
"""Update a chat session in both cache and database.
redis_key = f"chat:session:{session.session_id}"
Uses session-level locking to prevent race conditions when concurrent
operations (e.g., background title update and main stream handler)
attempt to upsert the same session simultaneously.
async_redis = await get_redis_async()
resp = await async_redis.setex(
redis_key, config.session_ttl, session.model_dump_json()
)
Raises:
DatabaseError: If the database write fails. The cache is still updated
as a best-effort optimization, but the error is propagated to ensure
callers are aware of the persistence failure.
RedisError: If the cache write fails (after successful DB write).
"""
# Acquire session-specific lock to prevent concurrent upserts
lock = await _get_session_lock(session.session_id)
if not resp:
raise RedisError(
f"Failed to persist chat session {session.session_id} to Redis: {resp}"
async with lock:
# Get existing message count from DB for incremental saves
existing_message_count = await chat_db.get_chat_session_message_count(
session.session_id
)
db_error: Exception | None = None
# Save to database (primary storage)
try:
await _save_session_to_db(session, existing_message_count)
except Exception as e:
logger.error(
f"Failed to save session {session.session_id} to database: {e}"
)
db_error = e
# Save to cache (best-effort, even if DB failed)
try:
await _cache_session(session)
except Exception as e:
# If DB succeeded but cache failed, raise cache error
if db_error is None:
raise RedisError(
f"Failed to persist chat session {session.session_id} to Redis: {e}"
) from e
# If both failed, log cache error but raise DB error (more critical)
logger.warning(
f"Cache write also failed for session {session.session_id}: {e}"
)
# Propagate DB error after attempting cache (prevents data loss)
if db_error is not None:
raise DatabaseError(
f"Failed to persist chat session {session.session_id} to database"
) from db_error
return session
async def create_chat_session(user_id: str | None) -> ChatSession:
"""Create a new chat session and persist it."""
session = ChatSession.new(user_id)
# Create in database first
try:
await chat_db.create_chat_session(
session_id=session.session_id,
user_id=user_id,
)
except Exception as e:
logger.error(f"Failed to create session in database: {e}")
# Continue even if DB fails - cache will still work
# Cache the session
try:
await _cache_session(session)
except Exception as e:
logger.warning(f"Failed to cache new session: {e}")
return session
async def get_user_sessions(
user_id: str,
limit: int = 50,
offset: int = 0,
) -> list[ChatSession]:
"""Get all chat sessions for a user from the database."""
prisma_sessions = await chat_db.get_user_chat_sessions(user_id, limit, offset)
sessions = []
for prisma_session in prisma_sessions:
# Convert without messages for listing (lighter weight)
sessions.append(ChatSession.from_prisma(prisma_session, None))
return sessions
async def delete_chat_session(session_id: str, user_id: str | None = None) -> bool:
"""Delete a chat session from both cache and database.
Args:
session_id: The session ID to delete.
user_id: If provided, validates that the session belongs to this user
before deletion. This prevents unauthorized deletion.
Returns:
True if deleted successfully, False otherwise.
"""
# Delete from cache (always attempt, regardless of ownership)
try:
redis_key = f"chat:session:{session_id}"
async_redis = await get_redis_async()
await async_redis.delete(redis_key)
except Exception as e:
logger.warning(f"Failed to delete session {session_id} from cache: {e}")
# Delete from database (with optional user_id validation)
return await chat_db.delete_chat_session(session_id, user_id)
async def update_session_title(session_id: str, title: str) -> bool:
"""Update only the title of a chat session.
This is a lightweight operation that doesn't touch messages, avoiding
race conditions with concurrent message updates. Use this for background
title generation instead of upsert_chat_session.
Args:
session_id: The session ID to update.
title: The new title to set.
Returns:
True if updated successfully, False otherwise.
"""
try:
result = await chat_db.update_chat_session(session_id=session_id, title=title)
if result is None:
logger.warning(f"Session {session_id} not found for title update")
return False
# Invalidate cache so next fetch gets updated title
try:
redis_key = f"chat:session:{session_id}"
async_redis = await get_redis_async()
await async_redis.delete(redis_key)
except Exception as e:
logger.warning(f"Failed to invalidate cache for session {session_id}: {e}")
return True
except Exception as e:
logger.error(f"Failed to update title for session {session_id}: {e}")
return False

View File

@@ -68,3 +68,50 @@ async def test_chatsession_redis_storage_user_id_mismatch():
s2 = await get_chat_session(s.session_id, None)
assert s2 is None
@pytest.mark.asyncio(loop_scope="session")
async def test_chatsession_db_storage():
"""Test that messages are correctly saved to and loaded from DB (not cache)."""
from backend.data.redis_client import get_redis_async
# Create session with messages including assistant message
s = ChatSession.new(user_id=None)
s.messages = messages # Contains user, assistant, and tool messages
assert s.session_id is not None, "Session id is not set"
# Upsert to save to both cache and DB
s = await upsert_chat_session(s)
# Clear the Redis cache to force DB load
redis_key = f"chat:session:{s.session_id}"
async_redis = await get_redis_async()
await async_redis.delete(redis_key)
# Load from DB (cache was cleared)
s2 = await get_chat_session(
session_id=s.session_id,
user_id=s.user_id,
)
assert s2 is not None, "Session not found after loading from DB"
assert len(s2.messages) == len(
s.messages
), f"Message count mismatch: expected {len(s.messages)}, got {len(s2.messages)}"
# Verify all roles are present
roles = [m.role for m in s2.messages]
assert "user" in roles, f"User message missing. Roles found: {roles}"
assert "assistant" in roles, f"Assistant message missing. Roles found: {roles}"
assert "tool" in roles, f"Tool message missing. Roles found: {roles}"
# Verify message content
for orig, loaded in zip(s.messages, s2.messages):
assert orig.role == loaded.role, f"Role mismatch: {orig.role} != {loaded.role}"
assert (
orig.content == loaded.content
), f"Content mismatch for {orig.role}: {orig.content} != {loaded.content}"
if orig.tool_calls:
assert (
loaded.tool_calls is not None
), f"Tool calls missing for {orig.role} message"
assert len(orig.tool_calls) == len(loaded.tool_calls)

View File

@@ -1,104 +0,0 @@
You are Otto, an AI Co-Pilot and Forward Deployed Engineer for AutoGPT, an AI Business Automation tool. Your mission is to help users quickly find and set up AutoGPT agents to solve their business problems.
Here are the functions available to you:
<functions>
1. **find_agent** - Search for agents that solve the user's problem
2. **run_agent** - Run or schedule an agent (automatically handles setup)
</functions>
## HOW run_agent WORKS
The `run_agent` tool automatically handles the entire setup flow:
1. **First call** (no inputs) → Returns available inputs so user can decide what values to use
2. **Credentials check** → If missing, UI automatically prompts user to add them (you don't need to mention this)
3. **Execution** → Runs when you provide `inputs` OR set `use_defaults=true`
Parameters:
- `username_agent_slug` (required): Agent identifier like "creator/agent-name"
- `inputs`: Object with input values for the agent
- `use_defaults`: Set to `true` to run with default values (only after user confirms)
- `schedule_name` + `cron`: For scheduled execution
## WORKFLOW
1. **find_agent** - Search for agents that solve the user's problem
2. **run_agent** (first call, no inputs) - Get available inputs for the agent
3. **Ask user** what values they want to use OR if they want to use defaults
4. **run_agent** (second call) - Either with `inputs={...}` or `use_defaults=true`
## YOUR APPROACH
**Step 1: Understand the Problem**
- Ask maximum 1-2 targeted questions
- Focus on: What business problem are they solving?
- Move quickly to searching for solutions
**Step 2: Find Agents**
- Use `find_agent` immediately with relevant keywords
- Suggest the best option from search results
- Explain briefly how it solves their problem
**Step 3: Get Agent Inputs**
- Call `run_agent(username_agent_slug="creator/agent-name")` without inputs
- This returns the available inputs (required and optional)
- Present these to the user and ask what values they want
**Step 4: Run with User's Choice**
- If user provides values: `run_agent(username_agent_slug="...", inputs={...})`
- If user says "use defaults": `run_agent(username_agent_slug="...", use_defaults=true)`
- On success, share the agent link with the user
**For Scheduled Execution:**
- Add `schedule_name` and `cron` parameters
- Example: `run_agent(username_agent_slug="...", inputs={...}, schedule_name="Daily Report", cron="0 9 * * *")`
## FUNCTION CALL FORMAT
To call a function, use this exact format:
`<function_call>function_name(parameter="value")</function_call>`
Examples:
- `<function_call>find_agent(query="social media automation")</function_call>`
- `<function_call>run_agent(username_agent_slug="creator/agent-name")</function_call>` (get inputs)
- `<function_call>run_agent(username_agent_slug="creator/agent-name", inputs={"topic": "AI news"})</function_call>`
- `<function_call>run_agent(username_agent_slug="creator/agent-name", use_defaults=true)</function_call>`
## KEY RULES
**What You DON'T Do:**
- Don't help with login (frontend handles this)
- Don't mention or explain credentials to the user (frontend handles this automatically)
- Don't run agents without first showing available inputs to the user
- Don't use `use_defaults=true` without user explicitly confirming
- Don't write responses longer than 3 sentences
**What You DO:**
- Always call run_agent first without inputs to see what's available
- Ask user what values they want OR if they want to use defaults
- Keep all responses to maximum 3 sentences
- Include the agent link in your response after successful execution
**Error Handling:**
- Authentication needed → "Please sign in via the interface"
- Credentials missing → The UI handles this automatically. Focus on asking the user about input values instead.
## RESPONSE STRUCTURE
Before responding, wrap your analysis in <thinking> tags to systematically plan your approach:
- Extract the key business problem or request from the user's message
- Determine what function call (if any) you need to make next
- Plan your response to stay under the 3-sentence maximum
Example interaction:
```
User: "Run the AI news agent for me"
Otto: <function_call>run_agent(username_agent_slug="autogpt/ai-news")</function_call>
[Tool returns: Agent accepts inputs - Required: topic. Optional: num_articles (default: 5)]
Otto: The AI News agent needs a topic. What topic would you like news about, or should I use the defaults?
User: "Use defaults"
Otto: <function_call>run_agent(username_agent_slug="autogpt/ai-news", use_defaults=true)</function_call>
```
KEEP ANSWERS TO 3 SENTENCES

View File

@@ -1,3 +1,10 @@
"""
Response models for Vercel AI SDK UI Stream Protocol.
This module implements the AI SDK UI Stream Protocol (v1) for streaming chat responses.
See: https://ai-sdk.dev/docs/ai-sdk-ui/stream-protocol
"""
from enum import Enum
from typing import Any
@@ -5,97 +12,133 @@ from pydantic import BaseModel, Field
class ResponseType(str, Enum):
"""Types of streaming responses."""
"""Types of streaming responses following AI SDK protocol."""
TEXT_CHUNK = "text_chunk"
TEXT_ENDED = "text_ended"
TOOL_CALL = "tool_call"
TOOL_CALL_START = "tool_call_start"
TOOL_RESPONSE = "tool_response"
# Message lifecycle
START = "start"
FINISH = "finish"
# Text streaming
TEXT_START = "text-start"
TEXT_DELTA = "text-delta"
TEXT_END = "text-end"
# Tool interaction
TOOL_INPUT_START = "tool-input-start"
TOOL_INPUT_AVAILABLE = "tool-input-available"
TOOL_OUTPUT_AVAILABLE = "tool-output-available"
# Other
ERROR = "error"
USAGE = "usage"
STREAM_END = "stream_end"
class StreamBaseResponse(BaseModel):
"""Base response model for all streaming responses."""
type: ResponseType
timestamp: str | None = None
def to_sse(self) -> str:
"""Convert to SSE format."""
return f"data: {self.model_dump_json()}\n\n"
class StreamTextChunk(StreamBaseResponse):
"""Streaming text content from the assistant."""
type: ResponseType = ResponseType.TEXT_CHUNK
content: str = Field(..., description="Text content chunk")
# ========== Message Lifecycle ==========
class StreamToolCallStart(StreamBaseResponse):
class StreamStart(StreamBaseResponse):
"""Start of a new message."""
type: ResponseType = ResponseType.START
messageId: str = Field(..., description="Unique message ID")
class StreamFinish(StreamBaseResponse):
"""End of message/stream."""
type: ResponseType = ResponseType.FINISH
# ========== Text Streaming ==========
class StreamTextStart(StreamBaseResponse):
"""Start of a text block."""
type: ResponseType = ResponseType.TEXT_START
id: str = Field(..., description="Text block ID")
class StreamTextDelta(StreamBaseResponse):
"""Streaming text content delta."""
type: ResponseType = ResponseType.TEXT_DELTA
id: str = Field(..., description="Text block ID")
delta: str = Field(..., description="Text content delta")
class StreamTextEnd(StreamBaseResponse):
"""End of a text block."""
type: ResponseType = ResponseType.TEXT_END
id: str = Field(..., description="Text block ID")
# ========== Tool Interaction ==========
class StreamToolInputStart(StreamBaseResponse):
"""Tool call started notification."""
type: ResponseType = ResponseType.TOOL_CALL_START
tool_name: str = Field(..., description="Name of the tool that was executed")
tool_id: str = Field(..., description="Unique tool call ID")
type: ResponseType = ResponseType.TOOL_INPUT_START
toolCallId: str = Field(..., description="Unique tool call ID")
toolName: str = Field(..., description="Name of the tool being called")
class StreamToolCall(StreamBaseResponse):
"""Tool invocation notification."""
class StreamToolInputAvailable(StreamBaseResponse):
"""Tool input is ready for execution."""
type: ResponseType = ResponseType.TOOL_CALL
tool_id: str = Field(..., description="Unique tool call ID")
tool_name: str = Field(..., description="Name of the tool being called")
arguments: dict[str, Any] = Field(
default_factory=dict, description="Tool arguments"
type: ResponseType = ResponseType.TOOL_INPUT_AVAILABLE
toolCallId: str = Field(..., description="Unique tool call ID")
toolName: str = Field(..., description="Name of the tool being called")
input: dict[str, Any] = Field(
default_factory=dict, description="Tool input arguments"
)
class StreamToolExecutionResult(StreamBaseResponse):
class StreamToolOutputAvailable(StreamBaseResponse):
"""Tool execution result."""
type: ResponseType = ResponseType.TOOL_RESPONSE
tool_id: str = Field(..., description="Tool call ID this responds to")
tool_name: str = Field(..., description="Name of the tool that was executed")
result: str | dict[str, Any] = Field(..., description="Tool execution result")
type: ResponseType = ResponseType.TOOL_OUTPUT_AVAILABLE
toolCallId: str = Field(..., description="Tool call ID this responds to")
output: str | dict[str, Any] = Field(..., description="Tool execution output")
# Additional fields for internal use (not part of AI SDK spec but useful)
toolName: str | None = Field(
default=None, description="Name of the tool that was executed"
)
success: bool = Field(
default=True, description="Whether the tool execution succeeded"
)
# ========== Other ==========
class StreamUsage(StreamBaseResponse):
"""Token usage statistics."""
type: ResponseType = ResponseType.USAGE
prompt_tokens: int
completion_tokens: int
total_tokens: int
promptTokens: int = Field(..., description="Number of prompt tokens")
completionTokens: int = Field(..., description="Number of completion tokens")
totalTokens: int = Field(..., description="Total number of tokens")
class StreamError(StreamBaseResponse):
"""Error response."""
type: ResponseType = ResponseType.ERROR
message: str = Field(..., description="Error message")
errorText: str = Field(..., description="Error message text")
code: str | None = Field(default=None, description="Error code")
details: dict[str, Any] | None = Field(
default=None, description="Additional error details"
)
class StreamTextEnded(StreamBaseResponse):
"""Text streaming completed marker."""
type: ResponseType = ResponseType.TEXT_ENDED
class StreamEnd(StreamBaseResponse):
"""End of stream marker."""
type: ResponseType = ResponseType.STREAM_END
summary: dict[str, Any] | None = Field(
default=None, description="Stream summary statistics"
)

View File

@@ -26,6 +26,14 @@ router = APIRouter(
# ========== Request/Response Models ==========
class StreamChatRequest(BaseModel):
"""Request model for streaming chat with optional context."""
message: str
is_user_message: bool = True
context: dict[str, str] | None = None # {url: str, content: str}
class CreateSessionResponse(BaseModel):
"""Response model containing information on a newly created chat session."""
@@ -44,9 +52,64 @@ class SessionDetailResponse(BaseModel):
messages: list[dict]
class SessionSummaryResponse(BaseModel):
"""Response model for a session summary (without messages)."""
id: str
created_at: str
updated_at: str
title: str | None = None
class ListSessionsResponse(BaseModel):
"""Response model for listing chat sessions."""
sessions: list[SessionSummaryResponse]
total: int
# ========== Routes ==========
@router.get(
"/sessions",
dependencies=[Security(auth.requires_user)],
)
async def list_sessions(
user_id: Annotated[str, Security(auth.get_user_id)],
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
) -> ListSessionsResponse:
"""
List chat sessions for the authenticated user.
Returns a paginated list of chat sessions belonging to the current user,
ordered by most recently updated.
Args:
user_id: The authenticated user's ID.
limit: Maximum number of sessions to return (1-100).
offset: Number of sessions to skip for pagination.
Returns:
ListSessionsResponse: List of session summaries and total count.
"""
sessions = await chat_service.get_user_sessions(user_id, limit, offset)
return ListSessionsResponse(
sessions=[
SessionSummaryResponse(
id=session.session_id,
created_at=session.started_at.isoformat(),
updated_at=session.updated_at.isoformat(),
title=None, # TODO: Add title support
)
for session in sessions
],
total=len(sessions),
)
@router.post(
"/sessions",
)
@@ -102,26 +165,92 @@ async def get_session(
session = await chat_service.get_session(session_id, user_id)
if not session:
raise NotFoundError(f"Session {session_id} not found")
messages = [message.model_dump() for message in session.messages]
logger.info(
f"Returning session {session_id}: "
f"message_count={len(messages)}, "
f"roles={[m.get('role') for m in messages]}"
)
return SessionDetailResponse(
id=session.session_id,
created_at=session.started_at.isoformat(),
updated_at=session.updated_at.isoformat(),
user_id=session.user_id or None,
messages=[message.model_dump() for message in session.messages],
messages=messages,
)
@router.post(
"/sessions/{session_id}/stream",
)
async def stream_chat_post(
session_id: str,
request: StreamChatRequest,
user_id: str | None = Depends(auth.get_user_id),
):
"""
Stream chat responses for a session (POST with context support).
Streams the AI/completion responses in real time over Server-Sent Events (SSE), including:
- Text fragments as they are generated
- Tool call UI elements (if invoked)
- Tool execution results
Args:
session_id: The chat session identifier to associate with the streamed messages.
request: Request body containing message, is_user_message, and optional context.
user_id: Optional authenticated user ID.
Returns:
StreamingResponse: SSE-formatted response chunks.
"""
# Validate session exists before starting the stream
# This prevents errors after the response has already started
session = await chat_service.get_session(session_id, user_id)
if not session:
raise NotFoundError(f"Session {session_id} not found. ")
if session.user_id is None and user_id is not None:
session = await chat_service.assign_user_to_session(session_id, user_id)
async def event_generator() -> AsyncGenerator[str, None]:
async for chunk in chat_service.stream_chat_completion(
session_id,
request.message,
is_user_message=request.is_user_message,
user_id=user_id,
session=session, # Pass pre-fetched session to avoid double-fetch
context=request.context,
):
yield chunk.to_sse()
# AI SDK protocol termination
yield "data: [DONE]\n\n"
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable nginx buffering
"x-vercel-ai-ui-message-stream": "v1", # AI SDK protocol header
},
)
@router.get(
"/sessions/{session_id}/stream",
)
async def stream_chat(
async def stream_chat_get(
session_id: str,
message: Annotated[str, Query(min_length=1, max_length=10000)],
user_id: str | None = Depends(auth.get_user_id),
is_user_message: bool = Query(default=True),
):
"""
Stream chat responses for a session.
Stream chat responses for a session (GET - legacy endpoint).
Streams the AI/completion responses in real time over Server-Sent Events (SSE), including:
- Text fragments as they are generated
@@ -155,6 +284,8 @@ async def stream_chat(
session=session, # Pass pre-fetched session to avoid double-fetch
):
yield chunk.to_sse()
# AI SDK protocol termination
yield "data: [DONE]\n\n"
return StreamingResponse(
event_generator(),
@@ -163,6 +294,7 @@ async def stream_chat(
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no", # Disable nginx buffering
"x-vercel-ai-ui-message-stream": "v1", # AI SDK protocol header
},
)

View File

@@ -1,31 +1,35 @@
import logging
from collections.abc import AsyncGenerator
from datetime import UTC, datetime
from typing import Any
import orjson
from langfuse import Langfuse
from openai import AsyncOpenAI
from openai.types.chat import ChatCompletionChunk, ChatCompletionToolParam
from backend.util.exceptions import NotFoundError
from .config import ChatConfig
from .model import (
ChatMessage,
ChatSession,
Usage,
get_chat_session,
upsert_chat_session,
from backend.data.understanding import (
format_understanding_for_prompt,
get_business_understanding,
)
from backend.util.exceptions import NotFoundError
from backend.util.settings import Settings
from . import db as chat_db
from .config import ChatConfig
from .model import ChatMessage, ChatSession, Usage
from .model import create_chat_session as model_create_chat_session
from .model import get_chat_session, update_session_title, upsert_chat_session
from .response_model import (
StreamBaseResponse,
StreamEnd,
StreamError,
StreamTextChunk,
StreamTextEnded,
StreamToolCall,
StreamToolCallStart,
StreamToolExecutionResult,
StreamFinish,
StreamStart,
StreamTextDelta,
StreamTextEnd,
StreamTextStart,
StreamToolInputAvailable,
StreamToolInputStart,
StreamToolOutputAvailable,
StreamUsage,
)
from .tools import execute_tool, tools
@@ -33,8 +37,146 @@ from .tools import execute_tool, tools
logger = logging.getLogger(__name__)
config = ChatConfig()
settings = Settings()
client = AsyncOpenAI(api_key=config.api_key, base_url=config.base_url)
# Langfuse client (lazy initialization)
_langfuse_client: Langfuse | None = None
def _get_langfuse_client() -> Langfuse:
"""Get or create the Langfuse client for prompt management and tracing."""
global _langfuse_client
if _langfuse_client is None:
if (
not settings.secrets.langfuse_public_key
or not settings.secrets.langfuse_secret_key
):
raise ValueError(
"Langfuse credentials not configured. "
"Set LANGFUSE_PUBLIC_KEY and LANGFUSE_SECRET_KEY environment variables."
)
_langfuse_client = Langfuse(
public_key=settings.secrets.langfuse_public_key,
secret_key=settings.secrets.langfuse_secret_key,
host=settings.secrets.langfuse_host or "https://cloud.langfuse.com",
)
return _langfuse_client
def _get_environment() -> str:
"""Get the current environment name for Langfuse tagging."""
return settings.config.app_env.value
def _get_langfuse_prompt() -> str:
"""Fetch the latest production prompt from Langfuse.
Returns:
The compiled prompt text from Langfuse.
Raises:
Exception: If Langfuse is unavailable or prompt fetch fails.
"""
try:
langfuse = _get_langfuse_client()
# cache_ttl_seconds=0 disables SDK caching to always get the latest prompt
prompt = langfuse.get_prompt(config.langfuse_prompt_name, cache_ttl_seconds=0)
compiled = prompt.compile()
logger.info(
f"Fetched prompt '{config.langfuse_prompt_name}' from Langfuse "
f"(version: {prompt.version})"
)
return compiled
except Exception as e:
logger.error(f"Failed to fetch prompt from Langfuse: {e}")
raise
async def _is_first_session(user_id: str) -> bool:
"""Check if this is the user's first chat session.
Returns True if the user has 1 or fewer sessions (meaning this is their first).
"""
try:
session_count = await chat_db.get_user_session_count(user_id)
return session_count <= 1
except Exception as e:
logger.warning(f"Failed to check session count for user {user_id}: {e}")
return False # Default to non-onboarding if we can't check
async def _build_system_prompt(user_id: str | None) -> tuple[str, Any]:
"""Build the full system prompt including business understanding if available.
Args:
user_id: The user ID for fetching business understanding
If "default" and this is the user's first session, will use "onboarding" instead.
Returns:
Tuple of (compiled prompt string, Langfuse prompt object for tracing)
"""
langfuse = _get_langfuse_client()
# cache_ttl_seconds=0 disables SDK caching to always get the latest prompt
prompt = langfuse.get_prompt(config.langfuse_prompt_name, cache_ttl_seconds=0)
# If user is authenticated, try to fetch their business understanding
understanding = None
if user_id:
try:
understanding = await get_business_understanding(user_id)
except Exception as e:
logger.warning(f"Failed to fetch business understanding: {e}")
understanding = None
if understanding:
context = format_understanding_for_prompt(understanding)
else:
context = "This is the first time you are meeting the user. Greet them and introduce them to the platform"
compiled = prompt.compile(users_information=context)
return compiled, prompt
async def _generate_session_title(message: str) -> str | None:
"""Generate a concise title for a chat session based on the first message.
Args:
message: The first user message in the session
Returns:
A short title (3-6 words) or None if generation fails
"""
try:
response = await client.chat.completions.create(
model=config.title_model,
messages=[
{
"role": "system",
"content": (
"Generate a very short title (3-6 words) for a chat conversation "
"based on the user's first message. The title should capture the "
"main topic or intent. Return ONLY the title, no quotes or punctuation."
),
},
{"role": "user", "content": message[:500]}, # Limit input length
],
max_tokens=20,
)
title = response.choices[0].message.content
if title:
# Clean up the title
title = title.strip().strip("\"'")
# Limit length
if len(title) > 50:
title = title[:47] + "..."
return title
return None
except Exception as e:
logger.warning(f"Failed to generate session title: {e}")
return None
async def create_chat_session(
user_id: str | None = None,
@@ -42,9 +184,7 @@ async def create_chat_session(
"""
Create a new chat session and persist it to the database.
"""
session = ChatSession.new(user_id)
# Persist the session immediately so it can be used for streaming
return await upsert_chat_session(session)
return await model_create_chat_session(user_id)
async def get_session(
@@ -57,6 +197,19 @@ async def get_session(
return await get_chat_session(session_id, user_id)
async def get_user_sessions(
user_id: str,
limit: int = 50,
offset: int = 0,
) -> list[ChatSession]:
"""
Get all chat sessions for a user.
"""
from .model import get_user_sessions as model_get_user_sessions
return await model_get_user_sessions(user_id, limit, offset)
async def assign_user_to_session(
session_id: str,
user_id: str,
@@ -78,6 +231,7 @@ async def stream_chat_completion(
user_id: str | None = None,
retry_count: int = 0,
session: ChatSession | None = None,
context: dict[str, str] | None = None, # {url: str, content: str}
) -> AsyncGenerator[StreamBaseResponse, None]:
"""Main entry point for streaming chat completions with database handling.
@@ -102,6 +256,9 @@ async def stream_chat_completion(
f"Streaming chat completion for session {session_id} for message {message} and user id {user_id}. Message is user message: {is_user_message}"
)
# Langfuse trace will be created after session is loaded (need messages for input)
trace = None
# Only fetch from Redis if session not provided (initial call)
if session is None:
session = await get_chat_session(session_id, user_id)
@@ -121,9 +278,18 @@ async def stream_chat_completion(
)
if message:
# Build message content with context if provided
message_content = message
if context and context.get("url") and context.get("content"):
context_text = f"Page URL: {context['url']}\n\nPage Content:\n{context['content']}\n\n---\n\nUser Message: {message}"
message_content = context_text
logger.info(
f"Including page context: URL={context['url']}, content_length={len(context['content'])}"
)
session.messages.append(
ChatMessage(
role="user" if is_user_message else "assistant", content=message
role="user" if is_user_message else "assistant", content=message_content
)
)
logger.info(
@@ -141,6 +307,63 @@ async def stream_chat_completion(
session = await upsert_chat_session(session)
assert session, "Session not found"
# Generate title for new sessions on first user message (non-blocking)
# Check: is_user_message, no title yet, and this is the first user message
if is_user_message and message and not session.title:
user_messages = [m for m in session.messages if m.role == "user"]
if len(user_messages) == 1:
# First user message - generate title in background
import asyncio
# Capture only the values we need (not the session object) to avoid
# stale data issues when the main flow modifies the session
captured_session_id = session_id
captured_message = message
async def _update_title():
try:
title = await _generate_session_title(captured_message)
if title:
# Use dedicated title update function that doesn't
# touch messages, avoiding race conditions
await update_session_title(captured_session_id, title)
logger.info(
f"Generated title for session {captured_session_id}: {title}"
)
except Exception as e:
logger.warning(f"Failed to update session title: {e}")
# Fire and forget - don't block the chat response
asyncio.create_task(_update_title())
# Build system prompt with business understanding
system_prompt, langfuse_prompt = await _build_system_prompt(user_id)
# Create Langfuse trace for this LLM call (each call gets its own trace, grouped by session_id)
# Using v3 SDK: start_observation creates a root span, update_trace sets trace-level attributes
try:
langfuse = _get_langfuse_client()
env = _get_environment()
trace = langfuse.start_observation(
name="chat_completion",
input={"messages": [m.model_dump() for m in session.messages]},
metadata={
"environment": env,
"model": config.model,
"message_count": len(session.messages),
"prompt_name": langfuse_prompt.name if langfuse_prompt else None,
"prompt_version": langfuse_prompt.version if langfuse_prompt else None,
},
)
# Set trace-level attributes (session_id, user_id, tags)
trace.update_trace(
session_id=session_id,
user_id=user_id,
tags=[env, "copilot"],
)
except Exception as e:
logger.warning(f"Failed to create Langfuse trace: {e}")
assistant_response = ChatMessage(
role="assistant",
content="",
@@ -155,57 +378,91 @@ async def stream_chat_completion(
accumulated_tool_calls: list[dict[str, Any]] = []
should_retry = False
# Generate unique IDs for AI SDK protocol
import uuid as uuid_module
message_id = str(uuid_module.uuid4())
text_block_id = str(uuid_module.uuid4())
# Yield message start
yield StreamStart(messageId=message_id)
# Create Langfuse generation for each LLM call, linked to the prompt
# Using v3 SDK: start_observation with as_type="generation"
generation = (
trace.start_observation(
as_type="generation",
name="llm_call",
model=config.model,
input={"messages": [m.model_dump() for m in session.messages]},
prompt=langfuse_prompt,
)
if trace
else None
)
try:
async for chunk in _stream_chat_chunks(
session=session,
tools=tools,
system_prompt=system_prompt,
text_block_id=text_block_id,
):
if isinstance(chunk, StreamTextChunk):
content = chunk.content or ""
if isinstance(chunk, StreamTextStart):
# Emit text-start before first text delta
if not has_received_text:
yield chunk
elif isinstance(chunk, StreamTextDelta):
delta = chunk.delta or ""
assert assistant_response.content is not None
assistant_response.content += content
assistant_response.content += delta
has_received_text = True
yield chunk
elif isinstance(chunk, StreamToolCallStart):
# Emit text_ended before first tool call, but only if we've received text
elif isinstance(chunk, StreamTextEnd):
# Emit text-end after text completes
if has_received_text and not text_streaming_ended:
yield StreamTextEnded()
text_streaming_ended = True
yield chunk
elif isinstance(chunk, StreamToolInputStart):
# Emit text-end before first tool call, but only if we've received text
if has_received_text and not text_streaming_ended:
yield StreamTextEnd(id=text_block_id)
text_streaming_ended = True
yield chunk
elif isinstance(chunk, StreamToolCall):
elif isinstance(chunk, StreamToolInputAvailable):
# Accumulate tool calls in OpenAI format
accumulated_tool_calls.append(
{
"id": chunk.tool_id,
"id": chunk.toolCallId,
"type": "function",
"function": {
"name": chunk.tool_name,
"arguments": orjson.dumps(chunk.arguments).decode("utf-8"),
"name": chunk.toolName,
"arguments": orjson.dumps(chunk.input).decode("utf-8"),
},
}
)
elif isinstance(chunk, StreamToolExecutionResult):
elif isinstance(chunk, StreamToolOutputAvailable):
result_content = (
chunk.result
if isinstance(chunk.result, str)
else orjson.dumps(chunk.result).decode("utf-8")
chunk.output
if isinstance(chunk.output, str)
else orjson.dumps(chunk.output).decode("utf-8")
)
tool_response_messages.append(
ChatMessage(
role="tool",
content=result_content,
tool_call_id=chunk.tool_id,
tool_call_id=chunk.toolCallId,
)
)
has_done_tool_call = True
# Track if any tool execution failed
if not chunk.success:
logger.warning(
f"Tool {chunk.tool_name} (ID: {chunk.tool_id}) execution failed"
f"Tool {chunk.toolName} (ID: {chunk.toolCallId}) execution failed"
)
yield chunk
elif isinstance(chunk, StreamEnd):
elif isinstance(chunk, StreamFinish):
if not has_done_tool_call:
has_yielded_end = True
yield chunk
@@ -214,9 +471,9 @@ async def stream_chat_completion(
elif isinstance(chunk, StreamUsage):
session.usage.append(
Usage(
prompt_tokens=chunk.prompt_tokens,
completion_tokens=chunk.completion_tokens,
total_tokens=chunk.total_tokens,
prompt_tokens=chunk.promptTokens,
completion_tokens=chunk.completionTokens,
total_tokens=chunk.totalTokens,
)
)
else:
@@ -258,15 +515,10 @@ async def stream_chat_completion(
f"Max retries ({config.max_retries}) exceeded: {error_message}"
)
error_response = StreamError(
message=error_message,
timestamp=datetime.now(UTC).isoformat(),
)
error_response = StreamError(errorText=error_message)
yield error_response
if not has_yielded_end:
yield StreamEnd(
timestamp=datetime.now(UTC).isoformat(),
)
yield StreamFinish()
return
# Handle retry outside of exception handler to avoid nesting
@@ -327,10 +579,42 @@ async def stream_chat_completion(
):
yield chunk
# End Langfuse generation with output and usage
if generation:
latest_usage = session.usage[-1] if session.usage else None
generation.update(
model=config.model,
output={
"content": assistant_response.content,
"tool_calls": accumulated_tool_calls or None,
},
usage_details=(
{
"input": latest_usage.prompt_tokens,
"output": latest_usage.completion_tokens,
"total": latest_usage.total_tokens,
}
if latest_usage
else None
),
)
generation.end()
# Update trace with output and end the span
# Using v3 SDK: update_trace() for trace-level output, then end()
if trace:
if accumulated_tool_calls:
trace.update_trace(output={"tool_calls": accumulated_tool_calls})
else:
trace.update_trace(output={"response": assistant_response.content})
trace.end()
async def _stream_chat_chunks(
session: ChatSession,
tools: list[ChatCompletionToolParam],
system_prompt: str | None = None,
text_block_id: str | None = None,
) -> AsyncGenerator[StreamBaseResponse, None]:
"""
Pure streaming function for OpenAI chat completions with tool calling.
@@ -338,9 +622,9 @@ async def _stream_chat_chunks(
This function is database-agnostic and focuses only on streaming logic.
Args:
messages: Conversation context as ChatCompletionMessageParam list
session_id: Session ID
user_id: User ID for tool execution
session: Chat session with conversation history
tools: Available tools for the model
system_prompt: System prompt to prepend to messages
Yields:
SSE formatted JSON response objects
@@ -350,6 +634,17 @@ async def _stream_chat_chunks(
logger.info("Starting pure chat stream")
# Build messages with system prompt prepended
messages = session.to_openai_messages()
if system_prompt:
from openai.types.chat import ChatCompletionSystemMessageParam
system_message = ChatCompletionSystemMessageParam(
role="system",
content=system_prompt,
)
messages = [system_message] + messages
# Loop to handle tool calls and continue conversation
while True:
try:
@@ -358,10 +653,11 @@ async def _stream_chat_chunks(
# Create the stream with proper types
stream = await client.chat.completions.create(
model=model,
messages=session.to_openai_messages(),
messages=messages,
tools=tools,
tool_choice="auto",
stream=True,
stream_options={"include_usage": True},
)
# Variables to accumulate tool calls
@@ -371,14 +667,17 @@ async def _stream_chat_chunks(
# Track which tool call indices have had their start event emitted
emitted_start_for_idx: set[int] = set()
# Track if we've started the text block
text_started = False
# Process the stream
chunk: ChatCompletionChunk
async for chunk in stream:
if chunk.usage:
yield StreamUsage(
prompt_tokens=chunk.usage.prompt_tokens,
completion_tokens=chunk.usage.completion_tokens,
total_tokens=chunk.usage.total_tokens,
promptTokens=chunk.usage.prompt_tokens,
completionTokens=chunk.usage.completion_tokens,
totalTokens=chunk.usage.total_tokens,
)
if chunk.choices:
@@ -392,10 +691,14 @@ async def _stream_chat_chunks(
# Handle content streaming
if delta.content:
# Stream the text chunk
text_response = StreamTextChunk(
content=delta.content,
timestamp=datetime.now(UTC).isoformat(),
# Emit text-start on first text content
if not text_started and text_block_id:
yield StreamTextStart(id=text_block_id)
text_started = True
# Stream the text delta
text_response = StreamTextDelta(
id=text_block_id or "",
delta=delta.content,
)
yield text_response
@@ -437,16 +740,15 @@ async def _stream_chat_chunks(
"arguments"
] += tc_chunk.function.arguments
# Emit StreamToolCallStart only after we have the tool call ID
# Emit StreamToolInputStart only after we have the tool call ID
if (
idx not in emitted_start_for_idx
and tool_calls[idx]["id"]
and tool_calls[idx]["function"]["name"]
):
yield StreamToolCallStart(
tool_id=tool_calls[idx]["id"],
tool_name=tool_calls[idx]["function"]["name"],
timestamp=datetime.now(UTC).isoformat(),
yield StreamToolInputStart(
toolCallId=tool_calls[idx]["id"],
toolName=tool_calls[idx]["function"]["name"],
)
emitted_start_for_idx.add(idx)
logger.info(f"Stream complete. Finish reason: {finish_reason}")
@@ -464,26 +766,18 @@ async def _stream_chat_chunks(
extra={"tool_call": tool_call},
)
yield StreamError(
message=f"Invalid tool call arguments for tool {tool_call.get('function', {}).get('name', 'unknown')}: {e}",
timestamp=datetime.now(UTC).isoformat(),
errorText=f"Invalid tool call arguments for tool {tool_call.get('function', {}).get('name', 'unknown')}: {e}",
)
# Re-raise to trigger retry logic in the parent function
raise
yield StreamEnd(
timestamp=datetime.now(UTC).isoformat(),
)
yield StreamFinish()
return
except Exception as e:
logger.error(f"Error in stream: {e!s}", exc_info=True)
error_response = StreamError(
message=str(e),
timestamp=datetime.now(UTC).isoformat(),
)
error_response = StreamError(errorText=str(e))
yield error_response
yield StreamEnd(
timestamp=datetime.now(UTC).isoformat(),
)
yield StreamFinish()
return
@@ -500,25 +794,31 @@ async def _yield_tool_call(
KeyError: If expected tool call fields are missing
TypeError: If tool call structure is invalid
"""
tool_name = tool_calls[yield_idx]["function"]["name"]
tool_call_id = tool_calls[yield_idx]["id"]
logger.info(f"Yielding tool call: {tool_calls[yield_idx]}")
# Parse tool call arguments - exceptions will propagate to caller
arguments = orjson.loads(tool_calls[yield_idx]["function"]["arguments"])
# Parse tool call arguments - handle empty arguments gracefully
raw_arguments = tool_calls[yield_idx]["function"]["arguments"]
if raw_arguments:
arguments = orjson.loads(raw_arguments)
else:
arguments = {}
yield StreamToolCall(
tool_id=tool_calls[yield_idx]["id"],
tool_name=tool_calls[yield_idx]["function"]["name"],
arguments=arguments,
timestamp=datetime.now(UTC).isoformat(),
yield StreamToolInputAvailable(
toolCallId=tool_call_id,
toolName=tool_name,
input=arguments,
)
tool_execution_response: StreamToolExecutionResult = await execute_tool(
tool_name=tool_calls[yield_idx]["function"]["name"],
tool_execution_response: StreamToolOutputAvailable = await execute_tool(
tool_name=tool_name,
parameters=arguments,
tool_call_id=tool_calls[yield_idx]["id"],
tool_call_id=tool_call_id,
user_id=session.user_id,
session=session,
)
logger.info(f"Yielding Tool execution response: {tool_execution_response}")
yield tool_execution_response

View File

@@ -5,10 +5,10 @@ import pytest
from . import service as chat_service
from .response_model import (
StreamEnd,
StreamError,
StreamTextChunk,
StreamToolExecutionResult,
StreamFinish,
StreamTextDelta,
StreamToolOutputAvailable,
)
logger = logging.getLogger(__name__)
@@ -34,9 +34,9 @@ async def test_stream_chat_completion():
logger.info(chunk)
if isinstance(chunk, StreamError):
has_errors = True
if isinstance(chunk, StreamTextChunk):
assistant_message += chunk.content
if isinstance(chunk, StreamEnd):
if isinstance(chunk, StreamTextDelta):
assistant_message += chunk.delta
if isinstance(chunk, StreamFinish):
has_ended = True
assert has_ended, "Chat completion did not end"
@@ -68,9 +68,9 @@ async def test_stream_chat_completion_with_tool_calls():
if isinstance(chunk, StreamError):
has_errors = True
if isinstance(chunk, StreamEnd):
if isinstance(chunk, StreamFinish):
has_ended = True
if isinstance(chunk, StreamToolExecutionResult):
if isinstance(chunk, StreamToolOutputAvailable):
had_tool_calls = True
assert has_ended, "Chat completion did not end"

View File

@@ -4,21 +4,30 @@ from openai.types.chat import ChatCompletionToolParam
from backend.api.features.chat.model import ChatSession
from .add_understanding import AddUnderstandingTool
from .agent_output import AgentOutputTool
from .base import BaseTool
from .find_agent import FindAgentTool
from .find_library_agent import FindLibraryAgentTool
from .run_agent import RunAgentTool
if TYPE_CHECKING:
from backend.api.features.chat.response_model import StreamToolExecutionResult
from backend.api.features.chat.response_model import StreamToolOutputAvailable
# Initialize tool instances
add_understanding_tool = AddUnderstandingTool()
find_agent_tool = FindAgentTool()
find_library_agent_tool = FindLibraryAgentTool()
run_agent_tool = RunAgentTool()
agent_output_tool = AgentOutputTool()
# Export tools as OpenAI format
tools: list[ChatCompletionToolParam] = [
add_understanding_tool.as_openai_tool(),
find_agent_tool.as_openai_tool(),
find_library_agent_tool.as_openai_tool(),
run_agent_tool.as_openai_tool(),
agent_output_tool.as_openai_tool(),
]
@@ -28,11 +37,14 @@ async def execute_tool(
user_id: str | None,
session: ChatSession,
tool_call_id: str,
) -> "StreamToolExecutionResult":
) -> "StreamToolOutputAvailable":
tool_map: dict[str, BaseTool] = {
"add_understanding": add_understanding_tool,
"find_agent": find_agent_tool,
"find_library_agent": find_library_agent_tool,
"run_agent": run_agent_tool,
"agent_output": agent_output_tool,
}
if tool_name not in tool_map:
raise ValueError(f"Tool {tool_name} not found")

View File

@@ -3,6 +3,7 @@ from datetime import UTC, datetime
from os import getenv
import pytest
from prisma.types import ProfileCreateInput
from pydantic import SecretStr
from backend.api.features.chat.model import ChatSession
@@ -49,13 +50,13 @@ async def setup_test_data():
# 1b. Create a profile with username for the user (required for store agent lookup)
username = user.email.split("@")[0]
await prisma.profile.create(
data={
"userId": user.id,
"username": username,
"name": f"Test User {username}",
"description": "Test user profile",
"links": [], # Required field - empty array for test profiles
}
data=ProfileCreateInput(
userId=user.id,
username=username,
name=f"Test User {username}",
description="Test user profile",
links=[], # Required field - empty array for test profiles
)
)
# 2. Create a test graph with agent input -> agent output
@@ -172,13 +173,13 @@ async def setup_llm_test_data():
# 1b. Create a profile with username for the user (required for store agent lookup)
username = user.email.split("@")[0]
await prisma.profile.create(
data={
"userId": user.id,
"username": username,
"name": f"Test User {username}",
"description": "Test user profile for LLM tests",
"links": [], # Required field - empty array for test profiles
}
data=ProfileCreateInput(
userId=user.id,
username=username,
name=f"Test User {username}",
description="Test user profile for LLM tests",
links=[], # Required field - empty array for test profiles
)
)
# 2. Create test OpenAI credentials for the user
@@ -332,13 +333,13 @@ async def setup_firecrawl_test_data():
# 1b. Create a profile with username for the user (required for store agent lookup)
username = user.email.split("@")[0]
await prisma.profile.create(
data={
"userId": user.id,
"username": username,
"name": f"Test User {username}",
"description": "Test user profile for Firecrawl tests",
"links": [], # Required field - empty array for test profiles
}
data=ProfileCreateInput(
userId=user.id,
username=username,
name=f"Test User {username}",
description="Test user profile for Firecrawl tests",
links=[], # Required field - empty array for test profiles
)
)
# NOTE: We deliberately do NOT create Firecrawl credentials for this user

View File

@@ -0,0 +1,119 @@
"""Tool for capturing user business understanding incrementally."""
import logging
from typing import Any
from backend.api.features.chat.model import ChatSession
from backend.data.understanding import (
BusinessUnderstandingInput,
upsert_business_understanding,
)
from .base import BaseTool
from .models import ErrorResponse, ToolResponseBase, UnderstandingUpdatedResponse
logger = logging.getLogger(__name__)
class AddUnderstandingTool(BaseTool):
"""Tool for capturing user's business understanding incrementally."""
@property
def name(self) -> str:
return "add_understanding"
@property
def description(self) -> str:
return """Capture and store information about the user's business context,
workflows, pain points, and automation goals. Call this tool whenever the user
shares information about their business. Each call incrementally adds to the
existing understanding - you don't need to provide all fields at once.
Use this to build a comprehensive profile that helps recommend better agents
and automations for the user's specific needs."""
@property
def parameters(self) -> dict[str, Any]:
# Auto-generate from Pydantic model schema
schema = BusinessUnderstandingInput.model_json_schema()
properties = {}
for field_name, field_schema in schema.get("properties", {}).items():
prop: dict[str, Any] = {"description": field_schema.get("description", "")}
# Handle anyOf for Optional types
if "anyOf" in field_schema:
for option in field_schema["anyOf"]:
if option.get("type") != "null":
prop["type"] = option.get("type", "string")
if "items" in option:
prop["items"] = option["items"]
break
else:
prop["type"] = field_schema.get("type", "string")
if "items" in field_schema:
prop["items"] = field_schema["items"]
properties[field_name] = prop
return {"type": "object", "properties": properties, "required": []}
@property
def requires_auth(self) -> bool:
"""Requires authentication to store user-specific data."""
return True
async def _execute(
self,
user_id: str | None,
session: ChatSession,
**kwargs,
) -> ToolResponseBase:
"""
Capture and store business understanding incrementally.
Each call merges new data with existing understanding:
- String fields are overwritten if provided
- List fields are appended (with deduplication)
"""
session_id = session.session_id
if not user_id:
return ErrorResponse(
message="Authentication required to save business understanding.",
session_id=session_id,
)
# Check if any data was provided
if not any(v is not None for v in kwargs.values()):
return ErrorResponse(
message="Please provide at least one field to update.",
session_id=session_id,
)
# Build input model from kwargs (only include fields defined in the model)
valid_fields = set(BusinessUnderstandingInput.model_fields.keys())
input_data = BusinessUnderstandingInput(
**{k: v for k, v in kwargs.items() if k in valid_fields}
)
# Track which fields were updated
updated_fields = [
k for k, v in kwargs.items() if k in valid_fields and v is not None
]
# Upsert with merge
understanding = await upsert_business_understanding(user_id, input_data)
# Build current understanding summary (filter out empty values)
current_understanding = {
k: v
for k, v in understanding.model_dump(
exclude={"id", "user_id", "created_at", "updated_at"}
).items()
if v is not None and v != [] and v != ""
}
return UnderstandingUpdatedResponse(
message=f"Updated understanding with: {', '.join(updated_fields)}. "
"I now have a better picture of your business context.",
session_id=session_id,
updated_fields=updated_fields,
current_understanding=current_understanding,
)

View File

@@ -0,0 +1,446 @@
"""Tool for retrieving agent execution outputs from user's library."""
import logging
import re
from datetime import datetime, timedelta, timezone
from typing import Any
from pydantic import BaseModel, field_validator
from backend.api.features.chat.model import ChatSession
from backend.api.features.library import db as library_db
from backend.api.features.library.model import LibraryAgent
from backend.data import execution as execution_db
from backend.data.execution import ExecutionStatus, GraphExecution, GraphExecutionMeta
from .base import BaseTool
from .models import (
AgentOutputResponse,
ErrorResponse,
ExecutionOutputInfo,
NoResultsResponse,
ToolResponseBase,
)
from .utils import fetch_graph_from_store_slug
logger = logging.getLogger(__name__)
class AgentOutputInput(BaseModel):
"""Input parameters for the agent_output tool."""
agent_name: str = ""
library_agent_id: str = ""
store_slug: str = ""
execution_id: str = ""
run_time: str = "latest"
@field_validator(
"agent_name",
"library_agent_id",
"store_slug",
"execution_id",
"run_time",
mode="before",
)
@classmethod
def strip_strings(cls, v: Any) -> Any:
"""Strip whitespace from string fields."""
return v.strip() if isinstance(v, str) else v
def parse_time_expression(
time_expr: str | None,
) -> tuple[datetime | None, datetime | None]:
"""
Parse time expression into datetime range (start, end).
Supports: "latest", "yesterday", "today", "last week", "last 7 days",
"last month", "last 30 days", ISO date "YYYY-MM-DD", ISO datetime.
"""
if not time_expr or time_expr.lower() == "latest":
return None, None
now = datetime.now(timezone.utc)
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
expr = time_expr.lower().strip()
# Relative time expressions lookup
relative_times: dict[str, tuple[datetime, datetime]] = {
"yesterday": (today_start - timedelta(days=1), today_start),
"today": (today_start, now),
"last week": (now - timedelta(days=7), now),
"last 7 days": (now - timedelta(days=7), now),
"last month": (now - timedelta(days=30), now),
"last 30 days": (now - timedelta(days=30), now),
}
if expr in relative_times:
return relative_times[expr]
# Try ISO date format (YYYY-MM-DD)
date_match = re.match(r"^(\d{4})-(\d{2})-(\d{2})$", expr)
if date_match:
try:
year, month, day = map(int, date_match.groups())
start = datetime(year, month, day, 0, 0, 0, tzinfo=timezone.utc)
return start, start + timedelta(days=1)
except ValueError:
# Invalid date components (e.g., month=13, day=32)
pass
# Try ISO datetime
try:
parsed = datetime.fromisoformat(expr.replace("Z", "+00:00"))
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed - timedelta(hours=1), parsed + timedelta(hours=1)
except ValueError:
return None, None
class AgentOutputTool(BaseTool):
"""Tool for retrieving execution outputs from user's library agents."""
@property
def name(self) -> str:
return "agent_output"
@property
def description(self) -> str:
return """Retrieve execution outputs from agents in the user's library.
Identify the agent using one of:
- agent_name: Fuzzy search in user's library
- library_agent_id: Exact library agent ID
- store_slug: Marketplace format 'username/agent-name'
Select which run to retrieve using:
- execution_id: Specific execution ID
- run_time: 'latest' (default), 'yesterday', 'last week', or ISO date 'YYYY-MM-DD'
"""
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"agent_name": {
"type": "string",
"description": "Agent name to search for in user's library (fuzzy match)",
},
"library_agent_id": {
"type": "string",
"description": "Exact library agent ID",
},
"store_slug": {
"type": "string",
"description": "Marketplace identifier: 'username/agent-slug'",
},
"execution_id": {
"type": "string",
"description": "Specific execution ID to retrieve",
},
"run_time": {
"type": "string",
"description": (
"Time filter: 'latest', 'yesterday', 'last week', or 'YYYY-MM-DD'"
),
},
},
"required": [],
}
@property
def requires_auth(self) -> bool:
return True
async def _resolve_agent(
self,
user_id: str,
agent_name: str | None,
library_agent_id: str | None,
store_slug: str | None,
) -> tuple[LibraryAgent | None, str | None]:
"""
Resolve agent from provided identifiers.
Returns (library_agent, error_message).
"""
# Priority 1: Exact library agent ID
if library_agent_id:
try:
agent = await library_db.get_library_agent(library_agent_id, user_id)
return agent, None
except Exception as e:
logger.warning(f"Failed to get library agent by ID: {e}")
return None, f"Library agent '{library_agent_id}' not found"
# Priority 2: Store slug (username/agent-name)
if store_slug and "/" in store_slug:
username, agent_slug = store_slug.split("/", 1)
graph, _ = await fetch_graph_from_store_slug(username, agent_slug)
if not graph:
return None, f"Agent '{store_slug}' not found in marketplace"
# Find in user's library by graph_id
agent = await library_db.get_library_agent_by_graph_id(user_id, graph.id)
if not agent:
return (
None,
f"Agent '{store_slug}' is not in your library. "
"Add it first to see outputs.",
)
return agent, None
# Priority 3: Fuzzy name search in library
if agent_name:
try:
response = await library_db.list_library_agents(
user_id=user_id,
search_term=agent_name,
page_size=5,
)
if not response.agents:
return (
None,
f"No agents matching '{agent_name}' found in your library",
)
# Return best match (first result from search)
return response.agents[0], None
except Exception as e:
logger.error(f"Error searching library agents: {e}")
return None, f"Error searching for agent: {e}"
return (
None,
"Please specify an agent name, library_agent_id, or store_slug",
)
async def _get_execution(
self,
user_id: str,
graph_id: str,
execution_id: str | None,
time_start: datetime | None,
time_end: datetime | None,
) -> tuple[GraphExecution | None, list[GraphExecutionMeta], str | None]:
"""
Fetch execution(s) based on filters.
Returns (single_execution, available_executions_meta, error_message).
"""
# If specific execution_id provided, fetch it directly
if execution_id:
execution = await execution_db.get_graph_execution(
user_id=user_id,
execution_id=execution_id,
include_node_executions=False,
)
if not execution:
return None, [], f"Execution '{execution_id}' not found"
return execution, [], None
# Get completed executions with time filters
executions = await execution_db.get_graph_executions(
graph_id=graph_id,
user_id=user_id,
statuses=[ExecutionStatus.COMPLETED],
created_time_gte=time_start,
created_time_lte=time_end,
limit=10,
)
if not executions:
return None, [], None # No error, just no executions
# If only one execution, fetch full details
if len(executions) == 1:
full_execution = await execution_db.get_graph_execution(
user_id=user_id,
execution_id=executions[0].id,
include_node_executions=False,
)
return full_execution, [], None
# Multiple executions - return latest with full details, plus list of available
full_execution = await execution_db.get_graph_execution(
user_id=user_id,
execution_id=executions[0].id,
include_node_executions=False,
)
return full_execution, executions, None
def _build_response(
self,
agent: LibraryAgent,
execution: GraphExecution | None,
available_executions: list[GraphExecutionMeta],
session_id: str | None,
) -> AgentOutputResponse:
"""Build the response based on execution data."""
library_agent_link = f"/library/agents/{agent.id}"
if not execution:
return AgentOutputResponse(
message=f"No completed executions found for agent '{agent.name}'",
session_id=session_id,
agent_name=agent.name,
agent_id=agent.graph_id,
library_agent_id=agent.id,
library_agent_link=library_agent_link,
total_executions=0,
)
execution_info = ExecutionOutputInfo(
execution_id=execution.id,
status=execution.status.value,
started_at=execution.started_at,
ended_at=execution.ended_at,
outputs=dict(execution.outputs),
inputs_summary=execution.inputs if execution.inputs else None,
)
available_list = None
if len(available_executions) > 1:
available_list = [
{
"id": e.id,
"status": e.status.value,
"started_at": e.started_at.isoformat() if e.started_at else None,
}
for e in available_executions[:5]
]
message = f"Found execution outputs for agent '{agent.name}'"
if len(available_executions) > 1:
message += (
f". Showing latest of {len(available_executions)} matching executions."
)
return AgentOutputResponse(
message=message,
session_id=session_id,
agent_name=agent.name,
agent_id=agent.graph_id,
library_agent_id=agent.id,
library_agent_link=library_agent_link,
execution=execution_info,
available_executions=available_list,
total_executions=len(available_executions) if available_executions else 1,
)
async def _execute(
self,
user_id: str | None,
session: ChatSession,
**kwargs,
) -> ToolResponseBase:
"""Execute the agent_output tool."""
session_id = session.session_id
# Parse and validate input
try:
input_data = AgentOutputInput(**kwargs)
except Exception as e:
logger.error(f"Invalid input: {e}")
return ErrorResponse(
message="Invalid input parameters",
error=str(e),
session_id=session_id,
)
# Ensure user_id is present (should be guaranteed by requires_auth)
if not user_id:
return ErrorResponse(
message="User authentication required",
session_id=session_id,
)
# Check if at least one identifier is provided
if not any(
[
input_data.agent_name,
input_data.library_agent_id,
input_data.store_slug,
input_data.execution_id,
]
):
return ErrorResponse(
message=(
"Please specify at least one of: agent_name, "
"library_agent_id, store_slug, or execution_id"
),
session_id=session_id,
)
# If only execution_id provided, we need to find the agent differently
if (
input_data.execution_id
and not input_data.agent_name
and not input_data.library_agent_id
and not input_data.store_slug
):
# Fetch execution directly to get graph_id
execution = await execution_db.get_graph_execution(
user_id=user_id,
execution_id=input_data.execution_id,
include_node_executions=False,
)
if not execution:
return ErrorResponse(
message=f"Execution '{input_data.execution_id}' not found",
session_id=session_id,
)
# Find library agent by graph_id
agent = await library_db.get_library_agent_by_graph_id(
user_id, execution.graph_id
)
if not agent:
return NoResultsResponse(
message=(
f"Execution found but agent not in your library. "
f"Graph ID: {execution.graph_id}"
),
session_id=session_id,
suggestions=["Add the agent to your library to see more details"],
)
return self._build_response(agent, execution, [], session_id)
# Resolve agent from identifiers
agent, error = await self._resolve_agent(
user_id=user_id,
agent_name=input_data.agent_name or None,
library_agent_id=input_data.library_agent_id or None,
store_slug=input_data.store_slug or None,
)
if error or not agent:
return NoResultsResponse(
message=error or "Agent not found",
session_id=session_id,
suggestions=[
"Check the agent name or ID",
"Make sure the agent is in your library",
],
)
# Parse time expression
time_start, time_end = parse_time_expression(input_data.run_time)
# Fetch execution(s)
execution, available_executions, exec_error = await self._get_execution(
user_id=user_id,
graph_id=agent.graph_id,
execution_id=input_data.execution_id or None,
time_start=time_start,
time_end=time_end,
)
if exec_error:
return ErrorResponse(
message=exec_error,
session_id=session_id,
)
return self._build_response(agent, execution, available_executions, session_id)

View File

@@ -0,0 +1,151 @@
"""Shared agent search functionality for find_agent and find_library_agent tools."""
import logging
from typing import Literal
from backend.api.features.library import db as library_db
from backend.api.features.store import db as store_db
from backend.util.exceptions import DatabaseError, NotFoundError
from .models import (
AgentInfo,
AgentsFoundResponse,
ErrorResponse,
NoResultsResponse,
ToolResponseBase,
)
logger = logging.getLogger(__name__)
SearchSource = Literal["marketplace", "library"]
async def search_agents(
query: str,
source: SearchSource,
session_id: str | None,
user_id: str | None = None,
) -> ToolResponseBase:
"""
Search for agents in marketplace or user library.
Args:
query: Search query string
source: "marketplace" or "library"
session_id: Chat session ID
user_id: User ID (required for library search)
Returns:
AgentsFoundResponse, NoResultsResponse, or ErrorResponse
"""
if not query:
return ErrorResponse(
message="Please provide a search query", session_id=session_id
)
if source == "library" and not user_id:
return ErrorResponse(
message="User authentication required to search library",
session_id=session_id,
)
agents: list[AgentInfo] = []
try:
if source == "marketplace":
logger.info(f"Searching marketplace for: {query}")
results = await store_db.get_store_agents(search_query=query, page_size=5)
for agent in results.agents:
agents.append(
AgentInfo(
id=f"{agent.creator}/{agent.slug}",
name=agent.agent_name,
description=agent.description or "",
source="marketplace",
in_library=False,
creator=agent.creator,
category="general",
rating=agent.rating,
runs=agent.runs,
is_featured=False,
)
)
else: # library
logger.info(f"Searching user library for: {query}")
results = await library_db.list_library_agents(
user_id=user_id, # type: ignore[arg-type]
search_term=query,
page_size=10,
)
for agent in results.agents:
agents.append(
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,
)
)
logger.info(f"Found {len(agents)} agents in {source}")
except NotFoundError:
pass
except DatabaseError as e:
logger.error(f"Error searching {source}: {e}", exc_info=True)
return ErrorResponse(
message=f"Failed to search {source}. Please try again.",
error=str(e),
session_id=session_id,
)
if not agents:
suggestions = (
[
"Try more general terms",
"Browse categories in the marketplace",
"Check spelling",
]
if source == "marketplace"
else [
"Try different keywords",
"Use find_agent to search the marketplace",
"Check your library at /library",
]
)
no_results_msg = (
f"No agents found matching '{query}'. Try different keywords or browse the marketplace."
if source == "marketplace"
else f"No agents matching '{query}' found in your library."
)
return NoResultsResponse(
message=no_results_msg, session_id=session_id, suggestions=suggestions
)
title = f"Found {len(agents)} agent{'s' if len(agents) != 1 else ''} "
title += (
f"for '{query}'"
if source == "marketplace"
else f"in your library for '{query}'"
)
message = (
"Now you have found some options for the user to choose from. "
"You can add a link to a recommended agent at: /marketplace/agent/agent_id "
"Please ask the user if they would like to use any of these agents."
if source == "marketplace"
else "Found agents in the user's library. You can provide a link to view an agent at: "
"/library/agents/{agent_id}. Use agent_output to get execution results, or run_agent to execute."
)
return AgentsFoundResponse(
message=message,
title=title,
agents=agents,
count=len(agents),
session_id=session_id,
)

View File

@@ -6,7 +6,7 @@ from typing import Any
from openai.types.chat import ChatCompletionToolParam
from backend.api.features.chat.model import ChatSession
from backend.api.features.chat.response_model import StreamToolExecutionResult
from backend.api.features.chat.response_model import StreamToolOutputAvailable
from .models import ErrorResponse, NeedLoginResponse, ToolResponseBase
@@ -53,7 +53,7 @@ class BaseTool:
session: ChatSession,
tool_call_id: str,
**kwargs,
) -> StreamToolExecutionResult:
) -> StreamToolOutputAvailable:
"""Execute the tool with authentication check.
Args:
@@ -69,10 +69,10 @@ class BaseTool:
logger.error(
f"Attempted tool call for {self.name} but user not authenticated"
)
return StreamToolExecutionResult(
tool_id=tool_call_id,
tool_name=self.name,
result=NeedLoginResponse(
return StreamToolOutputAvailable(
toolCallId=tool_call_id,
toolName=self.name,
output=NeedLoginResponse(
message=f"Please sign in to use {self.name}",
session_id=session.session_id,
).model_dump_json(),
@@ -81,17 +81,17 @@ class BaseTool:
try:
result = await self._execute(user_id, session, **kwargs)
return StreamToolExecutionResult(
tool_id=tool_call_id,
tool_name=self.name,
result=result.model_dump_json(),
return StreamToolOutputAvailable(
toolCallId=tool_call_id,
toolName=self.name,
output=result.model_dump_json(),
)
except Exception as e:
logger.error(f"Error in {self.name}: {e}", exc_info=True)
return StreamToolExecutionResult(
tool_id=tool_call_id,
tool_name=self.name,
result=ErrorResponse(
return StreamToolOutputAvailable(
toolCallId=tool_call_id,
toolName=self.name,
output=ErrorResponse(
message=f"An error occurred while executing {self.name}",
error=str(e),
session_id=session.session_id,

View File

@@ -1,26 +1,16 @@
"""Tool for discovering agents from marketplace and user library."""
"""Tool for discovering agents from marketplace."""
import logging
from typing import Any
from backend.api.features.chat.model import ChatSession
from backend.api.features.store import db as store_db
from backend.util.exceptions import DatabaseError, NotFoundError
from .agent_search import search_agents
from .base import BaseTool
from .models import (
AgentCarouselResponse,
AgentInfo,
ErrorResponse,
NoResultsResponse,
ToolResponseBase,
)
logger = logging.getLogger(__name__)
from .models import ToolResponseBase
class FindAgentTool(BaseTool):
"""Tool for discovering agents based on user needs."""
"""Tool for discovering agents from the marketplace."""
@property
def name(self) -> str:
@@ -46,84 +36,11 @@ class FindAgentTool(BaseTool):
}
async def _execute(
self,
user_id: str | None,
session: ChatSession,
**kwargs,
self, user_id: str | None, session: ChatSession, **kwargs
) -> ToolResponseBase:
"""Search for agents in the marketplace.
Args:
user_id: User ID (may be anonymous)
session_id: Chat session ID
query: Search query
Returns:
AgentCarouselResponse: List of agents found in the marketplace
NoResultsResponse: No agents found in the marketplace
ErrorResponse: Error message
"""
query = kwargs.get("query", "").strip()
session_id = session.session_id
if not query:
return ErrorResponse(
message="Please provide a search query",
session_id=session_id,
)
agents = []
try:
logger.info(f"Searching marketplace for: {query}")
store_results = await store_db.get_store_agents(
search_query=query,
page_size=5,
)
logger.info(f"Find agents tool found {len(store_results.agents)} agents")
for agent in store_results.agents:
agent_id = f"{agent.creator}/{agent.slug}"
logger.info(f"Building agent ID = {agent_id}")
agents.append(
AgentInfo(
id=agent_id,
name=agent.agent_name,
description=agent.description or "",
source="marketplace",
in_library=False,
creator=agent.creator,
category="general",
rating=agent.rating,
runs=agent.runs,
is_featured=False,
),
)
except NotFoundError:
pass
except DatabaseError as e:
logger.error(f"Error searching agents: {e}", exc_info=True)
return ErrorResponse(
message="Failed to search for agents. Please try again.",
error=str(e),
session_id=session_id,
)
if not agents:
return NoResultsResponse(
message=f"No agents found matching '{query}'. Try different keywords or browse the marketplace. If you have 3 consecutive find_agent tool calls results and found no agents. Please stop trying and ask the user if there is anything else you can help with.",
session_id=session_id,
suggestions=[
"Try more general terms",
"Browse categories in the marketplace",
"Check spelling",
],
)
# Return formatted carousel
title = (
f"Found {len(agents)} agent{'s' if len(agents) != 1 else ''} for '{query}'"
)
return AgentCarouselResponse(
message="Now you have found some options for the user to choose from. You can add a link to a recommended agent at: /marketplace/agent/agent_id Please ask the user if they would like to use any of these agents. If they do, please call the get_agent_details tool for this agent.",
title=title,
agents=agents,
count=len(agents),
session_id=session_id,
return await search_agents(
query=kwargs.get("query", "").strip(),
source="marketplace",
session_id=session.session_id,
user_id=user_id,
)

View File

@@ -0,0 +1,52 @@
"""Tool for searching agents in the user's library."""
from typing import Any
from backend.api.features.chat.model import ChatSession
from .agent_search import search_agents
from .base import BaseTool
from .models import ToolResponseBase
class FindLibraryAgentTool(BaseTool):
"""Tool for searching agents in the user's library."""
@property
def name(self) -> str:
return "find_library_agent"
@property
def description(self) -> str:
return (
"Search for agents in the user's library. Use this to find agents "
"the user has already added to their library, including agents they "
"created or added from the marketplace."
)
@property
def parameters(self) -> dict[str, Any]:
return {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query to find agents by name or description.",
},
},
"required": ["query"],
}
@property
def requires_auth(self) -> bool:
return True
async def _execute(
self, user_id: str | None, session: ChatSession, **kwargs
) -> ToolResponseBase:
return await search_agents(
query=kwargs.get("query", "").strip(),
source="library",
session_id=session.session_id,
user_id=user_id,
)

View File

@@ -1,5 +1,6 @@
"""Pydantic models for tool responses."""
from datetime import datetime
from enum import Enum
from typing import Any
@@ -11,14 +12,15 @@ from backend.data.model import CredentialsMetaInput
class ResponseType(str, Enum):
"""Types of tool responses."""
AGENT_CAROUSEL = "agent_carousel"
AGENTS_FOUND = "agents_found"
AGENT_DETAILS = "agent_details"
SETUP_REQUIREMENTS = "setup_requirements"
EXECUTION_STARTED = "execution_started"
NEED_LOGIN = "need_login"
ERROR = "error"
NO_RESULTS = "no_results"
SUCCESS = "success"
AGENT_OUTPUT = "agent_output"
UNDERSTANDING_UPDATED = "understanding_updated"
# Base response model
@@ -51,14 +53,14 @@ class AgentInfo(BaseModel):
graph_id: str | None = None
class AgentCarouselResponse(ToolResponseBase):
class AgentsFoundResponse(ToolResponseBase):
"""Response for find_agent tool."""
type: ResponseType = ResponseType.AGENT_CAROUSEL
type: ResponseType = ResponseType.AGENTS_FOUND
title: str = "Available Agents"
agents: list[AgentInfo]
count: int
name: str = "agent_carousel"
name: str = "agents_found"
class NoResultsResponse(ToolResponseBase):
@@ -173,3 +175,37 @@ class ErrorResponse(ToolResponseBase):
type: ResponseType = ResponseType.ERROR
error: str | None = None
details: dict[str, Any] | None = None
# Agent output models
class ExecutionOutputInfo(BaseModel):
"""Summary of a single execution's outputs."""
execution_id: str
status: str
started_at: datetime | None = None
ended_at: datetime | None = None
outputs: dict[str, list[Any]]
inputs_summary: dict[str, Any] | None = None
class AgentOutputResponse(ToolResponseBase):
"""Response for agent_output tool."""
type: ResponseType = ResponseType.AGENT_OUTPUT
agent_name: str
agent_id: str
library_agent_id: str | None = None
library_agent_link: str | None = None
execution: ExecutionOutputInfo | None = None
available_executions: list[dict[str, Any]] | None = None
total_executions: int = 0
# Business understanding models
class UnderstandingUpdatedResponse(ToolResponseBase):
"""Response for add_understanding tool."""
type: ResponseType = ResponseType.UNDERSTANDING_UPDATED
updated_fields: list[str] = Field(default_factory=list)
current_understanding: dict[str, Any] = Field(default_factory=dict)

View File

@@ -7,6 +7,7 @@ from pydantic import BaseModel, Field, field_validator
from backend.api.features.chat.config import ChatConfig
from backend.api.features.chat.model import ChatSession
from backend.api.features.library import db as library_db
from backend.data.graph import GraphModel
from backend.data.model import CredentialsMetaInput
from backend.data.user import get_user_by_id
@@ -57,6 +58,7 @@ class RunAgentInput(BaseModel):
"""Input parameters for the run_agent tool."""
username_agent_slug: str = ""
library_agent_id: str = ""
inputs: dict[str, Any] = Field(default_factory=dict)
use_defaults: bool = False
schedule_name: str = ""
@@ -64,7 +66,12 @@ class RunAgentInput(BaseModel):
timezone: str = "UTC"
@field_validator(
"username_agent_slug", "schedule_name", "cron", "timezone", mode="before"
"username_agent_slug",
"library_agent_id",
"schedule_name",
"cron",
"timezone",
mode="before",
)
@classmethod
def strip_strings(cls, v: Any) -> Any:
@@ -90,7 +97,7 @@ class RunAgentTool(BaseTool):
@property
def description(self) -> str:
return """Run or schedule an agent from the marketplace.
return """Run or schedule an agent from the marketplace or user's library.
The tool automatically handles the setup flow:
- Returns missing inputs if required fields are not provided
@@ -98,6 +105,10 @@ class RunAgentTool(BaseTool):
- Executes immediately if all requirements are met
- Schedules execution if cron expression is provided
Identify the agent using either:
- username_agent_slug: Marketplace format 'username/agent-name'
- library_agent_id: ID of an agent in the user's library
For scheduled execution, provide: schedule_name, cron, and optionally timezone."""
@property
@@ -109,6 +120,10 @@ class RunAgentTool(BaseTool):
"type": "string",
"description": "Agent identifier in format 'username/agent-name'",
},
"library_agent_id": {
"type": "string",
"description": "Library agent ID from user's library",
},
"inputs": {
"type": "object",
"description": "Input values for the agent",
@@ -131,7 +146,7 @@ class RunAgentTool(BaseTool):
"description": "IANA timezone for schedule (default: UTC)",
},
},
"required": ["username_agent_slug"],
"required": [],
}
@property
@@ -149,10 +164,16 @@ class RunAgentTool(BaseTool):
params = RunAgentInput(**kwargs)
session_id = session.session_id
# Validate agent slug format
if not params.username_agent_slug or "/" not in params.username_agent_slug:
# Validate at least one identifier is provided
has_slug = params.username_agent_slug and "/" in params.username_agent_slug
has_library_id = bool(params.library_agent_id)
if not has_slug and not has_library_id:
return ErrorResponse(
message="Please provide an agent slug in format 'username/agent-name'",
message=(
"Please provide either a username_agent_slug "
"(format 'username/agent-name') or a library_agent_id"
),
session_id=session_id,
)
@@ -167,13 +188,41 @@ class RunAgentTool(BaseTool):
is_schedule = bool(params.schedule_name or params.cron)
try:
# Step 1: Fetch agent details (always happens first)
username, agent_name = params.username_agent_slug.split("/", 1)
graph, store_agent = await fetch_graph_from_store_slug(username, agent_name)
# Step 1: Fetch agent details
graph: GraphModel | None = None
library_agent = None
# Priority: library_agent_id if provided
if has_library_id:
library_agent = await library_db.get_library_agent(
params.library_agent_id, user_id
)
if not library_agent:
return ErrorResponse(
message=f"Library agent '{params.library_agent_id}' not found",
session_id=session_id,
)
# Get the graph from the library agent
from backend.data.graph import get_graph
graph = await get_graph(
library_agent.graph_id,
library_agent.graph_version,
user_id=user_id,
)
else:
# Fetch from marketplace slug
username, agent_name = params.username_agent_slug.split("/", 1)
graph, _ = await fetch_graph_from_store_slug(username, agent_name)
if not graph:
identifier = (
params.library_agent_id
if has_library_id
else params.username_agent_slug
)
return ErrorResponse(
message=f"Agent '{params.username_agent_slug}' not found in marketplace",
message=f"Agent '{identifier}' not found",
session_id=session_id,
)

View File

@@ -46,11 +46,11 @@ async def test_run_agent(setup_test_data):
# Verify the response
assert response is not None
assert hasattr(response, "result")
assert hasattr(response, "output")
# Parse the result JSON to verify the execution started
assert isinstance(response.result, str)
result_data = orjson.loads(response.result)
assert isinstance(response.output, str)
result_data = orjson.loads(response.output)
assert "execution_id" in result_data
assert "graph_id" in result_data
assert result_data["graph_id"] == graph.id
@@ -86,11 +86,11 @@ async def test_run_agent_missing_inputs(setup_test_data):
# Verify that we get an error response
assert response is not None
assert hasattr(response, "result")
assert hasattr(response, "output")
# The tool should return an ErrorResponse when setup info indicates not ready
assert isinstance(response.result, str)
result_data = orjson.loads(response.result)
assert isinstance(response.output, str)
result_data = orjson.loads(response.output)
assert "message" in result_data
@@ -118,10 +118,10 @@ async def test_run_agent_invalid_agent_id(setup_test_data):
# Verify that we get an error response
assert response is not None
assert hasattr(response, "result")
assert hasattr(response, "output")
assert isinstance(response.result, str)
result_data = orjson.loads(response.result)
assert isinstance(response.output, str)
result_data = orjson.loads(response.output)
assert "message" in result_data
# Should get an error about failed setup or not found
assert any(
@@ -158,12 +158,12 @@ async def test_run_agent_with_llm_credentials(setup_llm_test_data):
# Verify the response
assert response is not None
assert hasattr(response, "result")
assert hasattr(response, "output")
# Parse the result JSON to verify the execution started
assert isinstance(response.result, str)
result_data = orjson.loads(response.result)
assert isinstance(response.output, str)
result_data = orjson.loads(response.output)
# Should successfully start execution since credentials are available
assert "execution_id" in result_data
@@ -195,9 +195,9 @@ async def test_run_agent_shows_available_inputs_when_none_provided(setup_test_da
)
assert response is not None
assert hasattr(response, "result")
assert isinstance(response.result, str)
result_data = orjson.loads(response.result)
assert hasattr(response, "output")
assert isinstance(response.output, str)
result_data = orjson.loads(response.output)
# Should return agent_details type showing available inputs
assert result_data.get("type") == "agent_details"
@@ -230,9 +230,9 @@ async def test_run_agent_with_use_defaults(setup_test_data):
)
assert response is not None
assert hasattr(response, "result")
assert isinstance(response.result, str)
result_data = orjson.loads(response.result)
assert hasattr(response, "output")
assert isinstance(response.output, str)
result_data = orjson.loads(response.output)
# Should execute successfully
assert "execution_id" in result_data
@@ -260,9 +260,9 @@ async def test_run_agent_missing_credentials(setup_firecrawl_test_data):
)
assert response is not None
assert hasattr(response, "result")
assert isinstance(response.result, str)
result_data = orjson.loads(response.result)
assert hasattr(response, "output")
assert isinstance(response.output, str)
result_data = orjson.loads(response.output)
# Should return setup_requirements type with missing credentials
assert result_data.get("type") == "setup_requirements"
@@ -292,9 +292,9 @@ async def test_run_agent_invalid_slug_format(setup_test_data):
)
assert response is not None
assert hasattr(response, "result")
assert isinstance(response.result, str)
result_data = orjson.loads(response.result)
assert hasattr(response, "output")
assert isinstance(response.output, str)
result_data = orjson.loads(response.output)
# Should return error
assert result_data.get("type") == "error"
@@ -318,9 +318,9 @@ async def test_run_agent_unauthenticated():
)
assert response is not None
assert hasattr(response, "result")
assert isinstance(response.result, str)
result_data = orjson.loads(response.result)
assert hasattr(response, "output")
assert isinstance(response.output, str)
result_data = orjson.loads(response.output)
# Base tool returns need_login type for unauthenticated users
assert result_data.get("type") == "need_login"
@@ -350,9 +350,9 @@ async def test_run_agent_schedule_without_cron(setup_test_data):
)
assert response is not None
assert hasattr(response, "result")
assert isinstance(response.result, str)
result_data = orjson.loads(response.result)
assert hasattr(response, "output")
assert isinstance(response.output, str)
result_data = orjson.loads(response.output)
# Should return error about missing cron
assert result_data.get("type") == "error"
@@ -382,9 +382,9 @@ async def test_run_agent_schedule_without_name(setup_test_data):
)
assert response is not None
assert hasattr(response, "result")
assert isinstance(response.result, str)
result_data = orjson.loads(response.result)
assert hasattr(response, "output")
assert isinstance(response.output, str)
result_data = orjson.loads(response.output)
# Should return error about missing schedule_name
assert result_data.get("type") == "error"

View File

@@ -108,9 +108,6 @@ class CredentialsMetaResponse(BaseModel):
host: str | None = Field(
default=None, description="Host pattern for host-scoped credentials"
)
is_system: bool = Field(
default=False, description="Whether this is a system-managed credential"
)
@router.post("/{provider}/callback", summary="Exchange OAuth code for tokens")
@@ -178,8 +175,6 @@ async def callback(
f"Successfully processed OAuth callback for user {user_id} "
f"and provider {provider.value}"
)
from backend.integrations.credentials_store import is_system_credential
return CredentialsMetaResponse(
id=credentials.id,
provider=credentials.provider,
@@ -190,7 +185,6 @@ async def callback(
host=(
credentials.host if isinstance(credentials, HostScopedCredentials) else None
),
is_system=is_system_credential(credentials.id),
)
@@ -198,8 +192,6 @@ async def callback(
async def list_credentials(
user_id: Annotated[str, Security(get_user_id)],
) -> list[CredentialsMetaResponse]:
from backend.integrations.credentials_store import is_system_credential
credentials = await creds_manager.store.get_all_creds(user_id)
return [
CredentialsMetaResponse(
@@ -210,7 +202,6 @@ async def list_credentials(
scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None,
username=cred.username if isinstance(cred, OAuth2Credentials) else None,
host=cred.host if isinstance(cred, HostScopedCredentials) else None,
is_system=is_system_credential(cred.id),
)
for cred in credentials
]
@@ -223,8 +214,6 @@ async def list_credentials_by_provider(
],
user_id: Annotated[str, Security(get_user_id)],
) -> list[CredentialsMetaResponse]:
from backend.integrations.credentials_store import is_system_credential
credentials = await creds_manager.store.get_creds_by_provider(user_id, provider)
return [
CredentialsMetaResponse(
@@ -235,7 +224,6 @@ async def list_credentials_by_provider(
scopes=cred.scopes if isinstance(cred, OAuth2Credentials) else None,
username=cred.username if isinstance(cred, OAuth2Credentials) else None,
host=cred.host if isinstance(cred, HostScopedCredentials) else None,
is_system=is_system_credential(cred.id),
)
for cred in credentials
]

View File

@@ -18,6 +18,7 @@ from backend.data.model import (
SchemaField,
)
from backend.integrations.providers import ProviderName
from backend.util.request import DEFAULT_USER_AGENT
class GetWikipediaSummaryBlock(Block, GetRequest):
@@ -39,17 +40,27 @@ class GetWikipediaSummaryBlock(Block, GetRequest):
output_schema=GetWikipediaSummaryBlock.Output,
test_input={"topic": "Artificial Intelligence"},
test_output=("summary", "summary content"),
test_mock={"get_request": lambda url, json: {"extract": "summary content"}},
test_mock={
"get_request": lambda url, headers, json: {"extract": "summary content"}
},
)
async def run(self, input_data: Input, **kwargs) -> BlockOutput:
topic = input_data.topic
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{topic}"
# URL-encode the topic to handle spaces and special characters
encoded_topic = quote(topic, safe="")
url = f"https://en.wikipedia.org/api/rest_v1/page/summary/{encoded_topic}"
# Set headers per Wikimedia robot policy (https://w.wiki/4wJS)
# - User-Agent: Required, must identify the bot
# - Accept-Encoding: gzip recommended to reduce bandwidth
headers = {
"User-Agent": DEFAULT_USER_AGENT,
"Accept-Encoding": "gzip, deflate",
}
# Note: User-Agent is now automatically set by the request library
# to comply with Wikimedia's robot policy (https://w.wiki/4wJS)
try:
response = await self.get_request(url, json=True)
response = await self.get_request(url, headers=headers, json=True)
if "extract" not in response:
raise ValueError(f"Unable to parse Wikipedia response: {response}")
yield "summary", response["extract"]

View File

@@ -0,0 +1,396 @@
"""Data models and access layer for user business understanding."""
import logging
from datetime import datetime
from typing import Any, Optional, cast
import pydantic
from prisma.models import UserBusinessUnderstanding
from prisma.types import (
UserBusinessUnderstandingCreateInput,
UserBusinessUnderstandingUpdateInput,
)
from backend.data.redis_client import get_redis_async
from backend.util.json import SafeJson
logger = logging.getLogger(__name__)
# Cache configuration
CACHE_KEY_PREFIX = "understanding"
CACHE_TTL_SECONDS = 48 * 60 * 60 # 48 hours
def _cache_key(user_id: str) -> str:
"""Generate cache key for user business understanding."""
return f"{CACHE_KEY_PREFIX}:{user_id}"
def _json_to_list(value: Any) -> list[str]:
"""Convert Json field to list[str], handling None."""
if value is None:
return []
if isinstance(value, list):
return cast(list[str], value)
return []
class BusinessUnderstandingInput(pydantic.BaseModel):
"""Input model for updating business understanding - all fields optional for incremental updates."""
# User info
user_name: Optional[str] = pydantic.Field(None, description="The user's name")
job_title: Optional[str] = pydantic.Field(None, description="The user's job title")
# Business basics
business_name: Optional[str] = pydantic.Field(
None, description="Name of the user's business"
)
industry: Optional[str] = pydantic.Field(None, description="Industry or sector")
business_size: Optional[str] = pydantic.Field(
None, description="Company size (e.g., '1-10', '11-50')"
)
user_role: Optional[str] = pydantic.Field(
None,
description="User's role in the organization (e.g., 'decision maker', 'implementer')",
)
# Processes & activities
key_workflows: Optional[list[str]] = pydantic.Field(
None, description="Key business workflows"
)
daily_activities: Optional[list[str]] = pydantic.Field(
None, description="Daily activities performed"
)
# Pain points & goals
pain_points: Optional[list[str]] = pydantic.Field(
None, description="Current pain points"
)
bottlenecks: Optional[list[str]] = pydantic.Field(
None, description="Process bottlenecks"
)
manual_tasks: Optional[list[str]] = pydantic.Field(
None, description="Manual/repetitive tasks"
)
automation_goals: Optional[list[str]] = pydantic.Field(
None, description="Desired automation goals"
)
# Current tools
current_software: Optional[list[str]] = pydantic.Field(
None, description="Software/tools currently used"
)
existing_automation: Optional[list[str]] = pydantic.Field(
None, description="Existing automations"
)
# Additional context
additional_notes: Optional[str] = pydantic.Field(
None, description="Any additional context"
)
class BusinessUnderstanding(pydantic.BaseModel):
"""Full business understanding model returned from database."""
id: str
user_id: str
created_at: datetime
updated_at: datetime
# User info
user_name: Optional[str] = None
job_title: Optional[str] = None
# Business basics
business_name: Optional[str] = None
industry: Optional[str] = None
business_size: Optional[str] = None
user_role: Optional[str] = None
# Processes & activities
key_workflows: list[str] = pydantic.Field(default_factory=list)
daily_activities: list[str] = pydantic.Field(default_factory=list)
# Pain points & goals
pain_points: list[str] = pydantic.Field(default_factory=list)
bottlenecks: list[str] = pydantic.Field(default_factory=list)
manual_tasks: list[str] = pydantic.Field(default_factory=list)
automation_goals: list[str] = pydantic.Field(default_factory=list)
# Current tools
current_software: list[str] = pydantic.Field(default_factory=list)
existing_automation: list[str] = pydantic.Field(default_factory=list)
# Additional context
additional_notes: Optional[str] = None
@classmethod
def from_db(cls, db_record: UserBusinessUnderstanding) -> "BusinessUnderstanding":
"""Convert database record to Pydantic model."""
return cls(
id=db_record.id,
user_id=db_record.userId,
created_at=db_record.createdAt,
updated_at=db_record.updatedAt,
user_name=db_record.usersName,
job_title=db_record.jobTitle,
business_name=db_record.businessName,
industry=db_record.industry,
business_size=db_record.businessSize,
user_role=db_record.userRole,
key_workflows=_json_to_list(db_record.keyWorkflows),
daily_activities=_json_to_list(db_record.dailyActivities),
pain_points=_json_to_list(db_record.painPoints),
bottlenecks=_json_to_list(db_record.bottlenecks),
manual_tasks=_json_to_list(db_record.manualTasks),
automation_goals=_json_to_list(db_record.automationGoals),
current_software=_json_to_list(db_record.currentSoftware),
existing_automation=_json_to_list(db_record.existingAutomation),
additional_notes=db_record.additionalNotes,
)
def _merge_lists(existing: list | None, new: list | None) -> list | None:
"""Merge two lists, removing duplicates while preserving order."""
if new is None:
return existing
if existing is None:
return new
# Preserve order, add new items that don't exist
merged = list(existing)
for item in new:
if item not in merged:
merged.append(item)
return merged
async def _get_from_cache(user_id: str) -> Optional[BusinessUnderstanding]:
"""Get business understanding from Redis cache."""
try:
redis = await get_redis_async()
cached_data = await redis.get(_cache_key(user_id))
if cached_data:
return BusinessUnderstanding.model_validate_json(cached_data)
except Exception as e:
logger.warning(f"Failed to get understanding from cache: {e}")
return None
async def _set_cache(user_id: str, understanding: BusinessUnderstanding) -> None:
"""Set business understanding in Redis cache with TTL."""
try:
redis = await get_redis_async()
await redis.setex(
_cache_key(user_id),
CACHE_TTL_SECONDS,
understanding.model_dump_json(),
)
except Exception as e:
logger.warning(f"Failed to set understanding in cache: {e}")
async def _delete_cache(user_id: str) -> None:
"""Delete business understanding from Redis cache."""
try:
redis = await get_redis_async()
await redis.delete(_cache_key(user_id))
except Exception as e:
logger.warning(f"Failed to delete understanding from cache: {e}")
async def get_business_understanding(
user_id: str,
) -> Optional[BusinessUnderstanding]:
"""Get the business understanding for a user.
Checks cache first, falls back to database if not cached.
Results are cached for 48 hours.
"""
# Try cache first
cached = await _get_from_cache(user_id)
if cached:
logger.debug(f"Business understanding cache hit for user {user_id}")
return cached
# Cache miss - load from database
logger.debug(f"Business understanding cache miss for user {user_id}")
record = await UserBusinessUnderstanding.prisma().find_unique(
where={"userId": user_id}
)
if record is None:
return None
understanding = BusinessUnderstanding.from_db(record)
# Store in cache for next time
await _set_cache(user_id, understanding)
return understanding
async def upsert_business_understanding(
user_id: str,
data: BusinessUnderstandingInput,
) -> BusinessUnderstanding:
"""
Create or update business understanding with incremental merge strategy.
- String fields: new value overwrites if provided (not None)
- List fields: new items are appended to existing (deduplicated)
"""
# Get existing record for merge
existing = await UserBusinessUnderstanding.prisma().find_unique(
where={"userId": user_id}
)
# Build update data with merge strategy
update_data: UserBusinessUnderstandingUpdateInput = {}
create_data: dict[str, Any] = {"userId": user_id}
# Field mappings: (pydantic_field, db_field)
string_fields = [
("user_name", "usersName"),
("job_title", "jobTitle"),
("business_name", "businessName"),
("industry", "industry"),
("business_size", "businessSize"),
("user_role", "userRole"),
("additional_notes", "additionalNotes"),
]
list_fields = [
("key_workflows", "keyWorkflows"),
("daily_activities", "dailyActivities"),
("pain_points", "painPoints"),
("bottlenecks", "bottlenecks"),
("manual_tasks", "manualTasks"),
("automation_goals", "automationGoals"),
("current_software", "currentSoftware"),
("existing_automation", "existingAutomation"),
]
# String fields - overwrite if provided
for pydantic_field, db_field in string_fields:
value = getattr(data, pydantic_field)
if value is not None:
update_data[db_field] = value # type: ignore[literal-required]
create_data[db_field] = value
# List fields - merge with existing
for pydantic_field, db_field in list_fields:
value = getattr(data, pydantic_field)
if value is not None:
existing_list = (
_json_to_list(getattr(existing, db_field)) if existing else None
)
merged = _merge_lists(existing_list, value)
update_data[db_field] = SafeJson(merged) # type: ignore[literal-required]
create_data[db_field] = SafeJson(merged)
# Upsert
record = await UserBusinessUnderstanding.prisma().upsert(
where={"userId": user_id},
data={
"create": UserBusinessUnderstandingCreateInput(**create_data),
"update": update_data,
},
)
understanding = BusinessUnderstanding.from_db(record)
# Update cache with new understanding
await _set_cache(user_id, understanding)
return understanding
async def clear_business_understanding(user_id: str) -> bool:
"""Clear/delete business understanding for a user from both DB and cache."""
# Delete from cache first
await _delete_cache(user_id)
try:
await UserBusinessUnderstanding.prisma().delete(where={"userId": user_id})
return True
except Exception:
# Record might not exist
return False
def format_understanding_for_prompt(understanding: BusinessUnderstanding) -> str:
"""Format business understanding as text for system prompt injection."""
sections = []
# User info section
user_info = []
if understanding.user_name:
user_info.append(f"Name: {understanding.user_name}")
if understanding.job_title:
user_info.append(f"Job Title: {understanding.job_title}")
if user_info:
sections.append("## User\n" + "\n".join(user_info))
# Business section
business_info = []
if understanding.business_name:
business_info.append(f"Company: {understanding.business_name}")
if understanding.industry:
business_info.append(f"Industry: {understanding.industry}")
if understanding.business_size:
business_info.append(f"Size: {understanding.business_size}")
if understanding.user_role:
business_info.append(f"Role Context: {understanding.user_role}")
if business_info:
sections.append("## Business\n" + "\n".join(business_info))
# Processes section
processes = []
if understanding.key_workflows:
processes.append(f"Key Workflows: {', '.join(understanding.key_workflows)}")
if understanding.daily_activities:
processes.append(
f"Daily Activities: {', '.join(understanding.daily_activities)}"
)
if processes:
sections.append("## Processes\n" + "\n".join(processes))
# Pain points section
pain_points = []
if understanding.pain_points:
pain_points.append(f"Pain Points: {', '.join(understanding.pain_points)}")
if understanding.bottlenecks:
pain_points.append(f"Bottlenecks: {', '.join(understanding.bottlenecks)}")
if understanding.manual_tasks:
pain_points.append(f"Manual Tasks: {', '.join(understanding.manual_tasks)}")
if pain_points:
sections.append("## Pain Points\n" + "\n".join(pain_points))
# Goals section
if understanding.automation_goals:
sections.append(
"## Automation Goals\n"
+ "\n".join(f"- {goal}" for goal in understanding.automation_goals)
)
# Current tools section
tools_info = []
if understanding.current_software:
tools_info.append(
f"Current Software: {', '.join(understanding.current_software)}"
)
if understanding.existing_automation:
tools_info.append(
f"Existing Automation: {', '.join(understanding.existing_automation)}"
)
if tools_info:
sections.append("## Current Tools\n" + "\n".join(tools_info))
# Additional notes
if understanding.additional_notes:
sections.append(f"## Additional Context\n{understanding.additional_notes}")
if not sections:
return ""
return "# User Business Context\n\n" + "\n\n".join(sections)

View File

@@ -245,13 +245,6 @@ DEFAULT_CREDENTIALS = [
webshare_proxy_credentials,
]
SYSTEM_CREDENTIAL_IDS = {cred.id for cred in DEFAULT_CREDENTIALS}
def is_system_credential(credential_id: str) -> bool:
"""Check if a credential ID belongs to a system-managed credential."""
return credential_id in SYSTEM_CREDENTIAL_IDS
class IntegrationCredentialsStore:
def __init__(self):

View File

@@ -658,6 +658,14 @@ class Secrets(UpdateTrackingModel["Secrets"], BaseSettings):
ayrshare_api_key: str = Field(default="", description="Ayrshare API Key")
ayrshare_jwt_key: str = Field(default="", description="Ayrshare private Key")
# Langfuse prompt management
langfuse_public_key: str = Field(default="", description="Langfuse public key")
langfuse_secret_key: str = Field(default="", description="Langfuse secret key")
langfuse_host: str = Field(
default="https://cloud.langfuse.com", description="Langfuse host URL"
)
# Add more secret fields as needed
model_config = SettingsConfigDict(
env_file=".env",

View File

@@ -0,0 +1,81 @@
-- DropIndex
DROP INDEX "StoreListingVersion_storeListingId_version_key";
-- CreateTable
CREATE TABLE "UserBusinessUnderstanding" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT NOT NULL,
"usersName" TEXT,
"jobTitle" TEXT,
"businessName" TEXT,
"industry" TEXT,
"businessSize" TEXT,
"userRole" TEXT,
"keyWorkflows" JSONB,
"dailyActivities" JSONB,
"painPoints" JSONB,
"bottlenecks" JSONB,
"manualTasks" JSONB,
"automationGoals" JSONB,
"currentSoftware" JSONB,
"existingAutomation" JSONB,
"additionalNotes" TEXT,
CONSTRAINT "UserBusinessUnderstanding_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatSession" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"userId" TEXT,
"title" TEXT,
"credentials" JSONB NOT NULL DEFAULT '{}',
"successfulAgentRuns" JSONB NOT NULL DEFAULT '{}',
"successfulAgentSchedules" JSONB NOT NULL DEFAULT '{}',
"totalPromptTokens" INTEGER NOT NULL DEFAULT 0,
"totalCompletionTokens" INTEGER NOT NULL DEFAULT 0,
CONSTRAINT "ChatSession_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "ChatMessage" (
"id" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"sessionId" TEXT NOT NULL,
"role" TEXT NOT NULL,
"content" TEXT,
"name" TEXT,
"toolCallId" TEXT,
"refusal" TEXT,
"toolCalls" JSONB,
"functionCall" JSONB,
"sequence" INTEGER NOT NULL,
CONSTRAINT "ChatMessage_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "UserBusinessUnderstanding_userId_key" ON "UserBusinessUnderstanding"("userId");
-- CreateIndex
CREATE INDEX "UserBusinessUnderstanding_userId_idx" ON "UserBusinessUnderstanding"("userId");
-- CreateIndex
CREATE INDEX "ChatSession_userId_updatedAt_idx" ON "ChatSession"("userId", "updatedAt");
-- CreateIndex
CREATE INDEX "ChatMessage_sessionId_sequence_idx" ON "ChatMessage"("sessionId", "sequence");
-- CreateIndex
CREATE UNIQUE INDEX "ChatMessage_sessionId_sequence_key" ON "ChatMessage"("sessionId", "sequence");
-- AddForeignKey
ALTER TABLE "UserBusinessUnderstanding" ADD CONSTRAINT "UserBusinessUnderstanding_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "ChatMessage" ADD CONSTRAINT "ChatMessage_sessionId_fkey" FOREIGN KEY ("sessionId") REFERENCES "ChatSession"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -2777,6 +2777,30 @@ enabler = ["pytest-enabler (>=2.2)"]
test = ["pyfakefs", "pytest (>=6,!=8.1.*)"]
type = ["pygobject-stubs", "pytest-mypy", "shtab", "types-pywin32"]
[[package]]
name = "langfuse"
version = "3.11.2"
description = "A client library for accessing langfuse"
optional = false
python-versions = "<4.0,>=3.10"
groups = ["main"]
files = [
{file = "langfuse-3.11.2-py3-none-any.whl", hash = "sha256:84faea9f909694023cc7f0eb45696be190248c8790424f22af57ca4cd7a29f2d"},
{file = "langfuse-3.11.2.tar.gz", hash = "sha256:ab5f296a8056815b7288c7f25bc308a5e79f82a8634467b25daffdde99276e09"},
]
[package.dependencies]
backoff = ">=1.10.0"
httpx = ">=0.15.4,<1.0"
openai = ">=0.27.8"
opentelemetry-api = ">=1.33.1,<2.0.0"
opentelemetry-exporter-otlp-proto-http = ">=1.33.1,<2.0.0"
opentelemetry-sdk = ">=1.33.1,<2.0.0"
packaging = ">=23.2,<26.0"
pydantic = ">=1.10.7,<3.0"
requests = ">=2,<3"
wrapt = ">=1.14,<2.0"
[[package]]
name = "launchdarkly-eventsource"
version = "1.3.0"
@@ -3468,6 +3492,90 @@ files = [
importlib-metadata = ">=6.0,<8.8.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.35.0"
description = "OpenTelemetry Protobuf encoding"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_exporter_otlp_proto_common-1.35.0-py3-none-any.whl", hash = "sha256:863465de697ae81279ede660f3918680b4480ef5f69dcdac04f30722ed7b74cc"},
{file = "opentelemetry_exporter_otlp_proto_common-1.35.0.tar.gz", hash = "sha256:6f6d8c39f629b9fa5c79ce19a2829dbd93034f8ac51243cdf40ed2196f00d7eb"},
]
[package.dependencies]
opentelemetry-proto = "1.35.0"
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.35.0"
description = "OpenTelemetry Collector Protobuf over HTTP Exporter"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_exporter_otlp_proto_http-1.35.0-py3-none-any.whl", hash = "sha256:9a001e3df3c7f160fb31056a28ed7faa2de7df68877ae909516102ae36a54e1d"},
{file = "opentelemetry_exporter_otlp_proto_http-1.35.0.tar.gz", hash = "sha256:cf940147f91b450ef5f66e9980d40eb187582eed399fa851f4a7a45bb880de79"},
]
[package.dependencies]
googleapis-common-protos = ">=1.52,<2.0"
opentelemetry-api = ">=1.15,<2.0"
opentelemetry-exporter-otlp-proto-common = "1.35.0"
opentelemetry-proto = "1.35.0"
opentelemetry-sdk = ">=1.35.0,<1.36.0"
requests = ">=2.7,<3.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-proto"
version = "1.35.0"
description = "OpenTelemetry Python Proto"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_proto-1.35.0-py3-none-any.whl", hash = "sha256:98fffa803164499f562718384e703be8d7dfbe680192279a0429cb150a2f8809"},
{file = "opentelemetry_proto-1.35.0.tar.gz", hash = "sha256:532497341bd3e1c074def7c5b00172601b28bb83b48afc41a4b779f26eb4ee05"},
]
[package.dependencies]
protobuf = ">=5.0,<7.0"
[[package]]
name = "opentelemetry-sdk"
version = "1.35.0"
description = "OpenTelemetry Python SDK"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_sdk-1.35.0-py3-none-any.whl", hash = "sha256:223d9e5f5678518f4842311bb73966e0b6db5d1e0b74e35074c052cd2487f800"},
{file = "opentelemetry_sdk-1.35.0.tar.gz", hash = "sha256:2a400b415ab68aaa6f04e8a6a9f6552908fb3090ae2ff78d6ae0c597ac581954"},
]
[package.dependencies]
opentelemetry-api = "1.35.0"
opentelemetry-semantic-conventions = "0.56b0"
typing-extensions = ">=4.5.0"
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.56b0"
description = "OpenTelemetry Semantic Conventions"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "opentelemetry_semantic_conventions-0.56b0-py3-none-any.whl", hash = "sha256:df44492868fd6b482511cc43a942e7194be64e94945f572db24df2e279a001a2"},
{file = "opentelemetry_semantic_conventions-0.56b0.tar.gz", hash = "sha256:c114c2eacc8ff6d3908cb328c811eaf64e6d68623840be9224dc829c4fd6c2ea"},
]
[package.dependencies]
opentelemetry-api = "1.35.0"
typing-extensions = ">=4.5.0"
[[package]]
name = "orjson"
version = "3.11.3"
@@ -6922,6 +7030,97 @@ files = [
{file = "websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee"},
]
[[package]]
name = "wrapt"
version = "1.17.3"
description = "Module for decorators, wrappers and monkey patching."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88bbae4d40d5a46142e70d58bf664a89b6b4befaea7b2ecc14e03cedb8e06c04"},
{file = "wrapt-1.17.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e6b13af258d6a9ad602d57d889f83b9d5543acd471eee12eb51f5b01f8eb1bc2"},
{file = "wrapt-1.17.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd341868a4b6714a5962c1af0bd44f7c404ef78720c7de4892901e540417111c"},
{file = "wrapt-1.17.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f9b2601381be482f70e5d1051a5965c25fb3625455a2bf520b5a077b22afb775"},
{file = "wrapt-1.17.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:343e44b2a8e60e06a7e0d29c1671a0d9951f59174f3709962b5143f60a2a98bd"},
{file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:33486899acd2d7d3066156b03465b949da3fd41a5da6e394ec49d271baefcf05"},
{file = "wrapt-1.17.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e6f40a8aa5a92f150bdb3e1c44b7e98fb7113955b2e5394122fa5532fec4b418"},
{file = "wrapt-1.17.3-cp310-cp310-win32.whl", hash = "sha256:a36692b8491d30a8c75f1dfee65bef119d6f39ea84ee04d9f9311f83c5ad9390"},
{file = "wrapt-1.17.3-cp310-cp310-win_amd64.whl", hash = "sha256:afd964fd43b10c12213574db492cb8f73b2f0826c8df07a68288f8f19af2ebe6"},
{file = "wrapt-1.17.3-cp310-cp310-win_arm64.whl", hash = "sha256:af338aa93554be859173c39c85243970dc6a289fa907402289eeae7543e1ae18"},
{file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:273a736c4645e63ac582c60a56b0acb529ef07f78e08dc6bfadf6a46b19c0da7"},
{file = "wrapt-1.17.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5531d911795e3f935a9c23eb1c8c03c211661a5060aab167065896bbf62a5f85"},
{file = "wrapt-1.17.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:0610b46293c59a3adbae3dee552b648b984176f8562ee0dba099a56cfbe4df1f"},
{file = "wrapt-1.17.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b32888aad8b6e68f83a8fdccbf3165f5469702a7544472bdf41f582970ed3311"},
{file = "wrapt-1.17.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cccf4f81371f257440c88faed6b74f1053eef90807b77e31ca057b2db74edb1"},
{file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8a210b158a34164de8bb68b0e7780041a903d7b00c87e906fb69928bf7890d5"},
{file = "wrapt-1.17.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:79573c24a46ce11aab457b472efd8d125e5a51da2d1d24387666cd85f54c05b2"},
{file = "wrapt-1.17.3-cp311-cp311-win32.whl", hash = "sha256:c31eebe420a9a5d2887b13000b043ff6ca27c452a9a22fa71f35f118e8d4bf89"},
{file = "wrapt-1.17.3-cp311-cp311-win_amd64.whl", hash = "sha256:0b1831115c97f0663cb77aa27d381237e73ad4f721391a9bfb2fe8bc25fa6e77"},
{file = "wrapt-1.17.3-cp311-cp311-win_arm64.whl", hash = "sha256:5a7b3c1ee8265eb4c8f1b7d29943f195c00673f5ab60c192eba2d4a7eae5f46a"},
{file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ab232e7fdb44cdfbf55fc3afa31bcdb0d8980b9b95c38b6405df2acb672af0e0"},
{file = "wrapt-1.17.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9baa544e6acc91130e926e8c802a17f3b16fbea0fd441b5a60f5cf2cc5c3deba"},
{file = "wrapt-1.17.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6b538e31eca1a7ea4605e44f81a48aa24c4632a277431a6ed3f328835901f4fd"},
{file = "wrapt-1.17.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:042ec3bb8f319c147b1301f2393bc19dba6e176b7da446853406d041c36c7828"},
{file = "wrapt-1.17.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3af60380ba0b7b5aeb329bc4e402acd25bd877e98b3727b0135cb5c2efdaefe9"},
{file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0b02e424deef65c9f7326d8c19220a2c9040c51dc165cddb732f16198c168396"},
{file = "wrapt-1.17.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:74afa28374a3c3a11b3b5e5fca0ae03bef8450d6aa3ab3a1e2c30e3a75d023dc"},
{file = "wrapt-1.17.3-cp312-cp312-win32.whl", hash = "sha256:4da9f45279fff3543c371d5ababc57a0384f70be244de7759c85a7f989cb4ebe"},
{file = "wrapt-1.17.3-cp312-cp312-win_amd64.whl", hash = "sha256:e71d5c6ebac14875668a1e90baf2ea0ef5b7ac7918355850c0908ae82bcb297c"},
{file = "wrapt-1.17.3-cp312-cp312-win_arm64.whl", hash = "sha256:604d076c55e2fdd4c1c03d06dc1a31b95130010517b5019db15365ec4a405fc6"},
{file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a47681378a0439215912ef542c45a783484d4dd82bac412b71e59cf9c0e1cea0"},
{file = "wrapt-1.17.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:54a30837587c6ee3cd1a4d1c2ec5d24e77984d44e2f34547e2323ddb4e22eb77"},
{file = "wrapt-1.17.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:16ecf15d6af39246fe33e507105d67e4b81d8f8d2c6598ff7e3ca1b8a37213f7"},
{file = "wrapt-1.17.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6fd1ad24dc235e4ab88cda009e19bf347aabb975e44fd5c2fb22a3f6e4141277"},
{file = "wrapt-1.17.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ed61b7c2d49cee3c027372df5809a59d60cf1b6c2f81ee980a091f3afed6a2d"},
{file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:423ed5420ad5f5529db9ce89eac09c8a2f97da18eb1c870237e84c5a5c2d60aa"},
{file = "wrapt-1.17.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e01375f275f010fcbf7f643b4279896d04e571889b8a5b3f848423d91bf07050"},
{file = "wrapt-1.17.3-cp313-cp313-win32.whl", hash = "sha256:53e5e39ff71b3fc484df8a522c933ea2b7cdd0d5d15ae82e5b23fde87d44cbd8"},
{file = "wrapt-1.17.3-cp313-cp313-win_amd64.whl", hash = "sha256:1f0b2f40cf341ee8cc1a97d51ff50dddb9fcc73241b9143ec74b30fc4f44f6cb"},
{file = "wrapt-1.17.3-cp313-cp313-win_arm64.whl", hash = "sha256:7425ac3c54430f5fc5e7b6f41d41e704db073309acfc09305816bc6a0b26bb16"},
{file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cf30f6e3c077c8e6a9a7809c94551203c8843e74ba0c960f4a98cd80d4665d39"},
{file = "wrapt-1.17.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:e228514a06843cae89621384cfe3a80418f3c04aadf8a3b14e46a7be704e4235"},
{file = "wrapt-1.17.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:5ea5eb3c0c071862997d6f3e02af1d055f381b1d25b286b9d6644b79db77657c"},
{file = "wrapt-1.17.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:281262213373b6d5e4bb4353bc36d1ba4084e6d6b5d242863721ef2bf2c2930b"},
{file = "wrapt-1.17.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc4a8d2b25efb6681ecacad42fca8859f88092d8732b170de6a5dddd80a1c8fa"},
{file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:373342dd05b1d07d752cecbec0c41817231f29f3a89aa8b8843f7b95992ed0c7"},
{file = "wrapt-1.17.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d40770d7c0fd5cbed9d84b2c3f2e156431a12c9a37dc6284060fb4bec0b7ffd4"},
{file = "wrapt-1.17.3-cp314-cp314-win32.whl", hash = "sha256:fbd3c8319de8e1dc79d346929cd71d523622da527cca14e0c1d257e31c2b8b10"},
{file = "wrapt-1.17.3-cp314-cp314-win_amd64.whl", hash = "sha256:e1a4120ae5705f673727d3253de3ed0e016f7cd78dc463db1b31e2463e1f3cf6"},
{file = "wrapt-1.17.3-cp314-cp314-win_arm64.whl", hash = "sha256:507553480670cab08a800b9463bdb881b2edeed77dc677b0a5915e6106e91a58"},
{file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:ed7c635ae45cfbc1a7371f708727bf74690daedc49b4dba310590ca0bd28aa8a"},
{file = "wrapt-1.17.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:249f88ed15503f6492a71f01442abddd73856a0032ae860de6d75ca62eed8067"},
{file = "wrapt-1.17.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a03a38adec8066d5a37bea22f2ba6bbf39fcdefbe2d91419ab864c3fb515454"},
{file = "wrapt-1.17.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:5d4478d72eb61c36e5b446e375bbc49ed002430d17cdec3cecb36993398e1a9e"},
{file = "wrapt-1.17.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:223db574bb38637e8230eb14b185565023ab624474df94d2af18f1cdb625216f"},
{file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e405adefb53a435f01efa7ccdec012c016b5a1d3f35459990afc39b6be4d5056"},
{file = "wrapt-1.17.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:88547535b787a6c9ce4086917b6e1d291aa8ed914fdd3a838b3539dc95c12804"},
{file = "wrapt-1.17.3-cp314-cp314t-win32.whl", hash = "sha256:41b1d2bc74c2cac6f9074df52b2efbef2b30bdfe5f40cb78f8ca22963bc62977"},
{file = "wrapt-1.17.3-cp314-cp314t-win_amd64.whl", hash = "sha256:73d496de46cd2cdbdbcce4ae4bcdb4afb6a11234a1df9c085249d55166b95116"},
{file = "wrapt-1.17.3-cp314-cp314t-win_arm64.whl", hash = "sha256:f38e60678850c42461d4202739f9bf1e3a737c7ad283638251e79cc49effb6b6"},
{file = "wrapt-1.17.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:70d86fa5197b8947a2fa70260b48e400bf2ccacdcab97bb7de47e3d1e6312225"},
{file = "wrapt-1.17.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:df7d30371a2accfe4013e90445f6388c570f103d61019b6b7c57e0265250072a"},
{file = "wrapt-1.17.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:caea3e9c79d5f0d2c6d9ab96111601797ea5da8e6d0723f77eabb0d4068d2b2f"},
{file = "wrapt-1.17.3-cp38-cp38-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:758895b01d546812d1f42204bd443b8c433c44d090248bf22689df673ccafe00"},
{file = "wrapt-1.17.3-cp38-cp38-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02b551d101f31694fc785e58e0720ef7d9a10c4e62c1c9358ce6f63f23e30a56"},
{file = "wrapt-1.17.3-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:656873859b3b50eeebe6db8b1455e99d90c26ab058db8e427046dbc35c3140a5"},
{file = "wrapt-1.17.3-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:a9a2203361a6e6404f80b99234fe7fb37d1fc73487b5a78dc1aa5b97201e0f22"},
{file = "wrapt-1.17.3-cp38-cp38-win32.whl", hash = "sha256:55cbbc356c2842f39bcc553cf695932e8b30e30e797f961860afb308e6b1bb7c"},
{file = "wrapt-1.17.3-cp38-cp38-win_amd64.whl", hash = "sha256:ad85e269fe54d506b240d2d7b9f5f2057c2aa9a2ea5b32c66f8902f768117ed2"},
{file = "wrapt-1.17.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:30ce38e66630599e1193798285706903110d4f057aab3168a34b7fdc85569afc"},
{file = "wrapt-1.17.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:65d1d00fbfb3ea5f20add88bbc0f815150dbbde3b026e6c24759466c8b5a9ef9"},
{file = "wrapt-1.17.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a7c06742645f914f26c7f1fa47b8bc4c91d222f76ee20116c43d5ef0912bba2d"},
{file = "wrapt-1.17.3-cp39-cp39-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7e18f01b0c3e4a07fe6dfdb00e29049ba17eadbc5e7609a2a3a4af83ab7d710a"},
{file = "wrapt-1.17.3-cp39-cp39-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f5f51a6466667a5a356e6381d362d259125b57f059103dd9fdc8c0cf1d14139"},
{file = "wrapt-1.17.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:59923aa12d0157f6b82d686c3fd8e1166fa8cdfb3e17b42ce3b6147ff81528df"},
{file = "wrapt-1.17.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:46acc57b331e0b3bcb3e1ca3b421d65637915cfcd65eb783cb2f78a511193f9b"},
{file = "wrapt-1.17.3-cp39-cp39-win32.whl", hash = "sha256:3e62d15d3cfa26e3d0788094de7b64efa75f3a53875cdbccdf78547aed547a81"},
{file = "wrapt-1.17.3-cp39-cp39-win_amd64.whl", hash = "sha256:1f23fa283f51c890eda8e34e4937079114c74b4c81d2b2f1f1d94948f5cc3d7f"},
{file = "wrapt-1.17.3-cp39-cp39-win_arm64.whl", hash = "sha256:24c2ed34dc222ed754247a2702b1e1e89fdbaa4016f324b4b8f1a802d4ffe87f"},
{file = "wrapt-1.17.3-py3-none-any.whl", hash = "sha256:7171ae35d2c33d326ac19dd8facb1e82e5fd04ef8c6c0e394d7af55a55051c22"},
{file = "wrapt-1.17.3.tar.gz", hash = "sha256:f66eb08feaa410fe4eebd17f2a2c8e2e46d3476e9f8c783daa8e09e0faa666d0"},
]
[[package]]
name = "xattr"
version = "1.2.0"
@@ -7295,4 +7494,4 @@ cffi = ["cffi (>=1.11)"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.10,<3.14"
content-hash = "a93ba0cea3b465cb6ec3e3f258b383b09f84ea352ccfdbfa112902cde5653fc6"
content-hash = "86838b5ae40d606d6e01a14dad8a56c389d890d7a6a0c274a6602cca80f0df84"

View File

@@ -33,6 +33,7 @@ html2text = "^2024.2.26"
jinja2 = "^3.1.6"
jsonref = "^1.1.0"
jsonschema = "^4.25.0"
langfuse = "^3.11.0"
launchdarkly-server-sdk = "^9.12.0"
mem0ai = "^0.1.115"
moviepy = "^2.1.2"

View File

@@ -53,6 +53,7 @@ model User {
Profile Profile[]
UserOnboarding UserOnboarding?
BusinessUnderstanding UserBusinessUnderstanding?
BuilderSearchHistory BuilderSearchHistory[]
StoreListings StoreListing[]
StoreListingReviews StoreListingReview[]
@@ -121,19 +122,109 @@ model UserOnboarding {
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model UserBusinessUnderstanding {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
userId String @unique
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
// User info
usersName String?
jobTitle String?
// Business basics (string columns)
businessName String?
industry String?
businessSize String? // "1-10", "11-50", "51-200", "201-1000", "1000+"
userRole String? // Role in organization context (e.g., "decision maker", "implementer")
// Processes & activities (JSON arrays)
keyWorkflows Json?
dailyActivities Json?
// Pain points & goals (JSON arrays)
painPoints Json?
bottlenecks Json?
manualTasks Json?
automationGoals Json?
// Current tools (JSON arrays)
currentSoftware Json?
existingAutomation Json?
additionalNotes String?
@@index([userId])
}
model BuilderSearchHistory {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
searchQuery String
filter String[] @default([])
byCreator String[] @default([])
filter String[] @default([])
byCreator String[] @default([])
userId String
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
//////////////// CHAT SESSION TABLES ///////////////////
////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////
model ChatSession {
id String @id @default(uuid())
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
userId String?
// Session metadata
title String?
credentials Json @default("{}") // Map of provider -> credential metadata
// Rate limiting counters (stored as JSON maps)
successfulAgentRuns Json @default("{}") // Map of graph_id -> count
successfulAgentSchedules Json @default("{}") // Map of graph_id -> count
// Usage tracking
totalPromptTokens Int @default(0)
totalCompletionTokens Int @default(0)
Messages ChatMessage[]
@@index([userId, updatedAt])
}
model ChatMessage {
id String @id @default(uuid())
createdAt DateTime @default(now())
sessionId String
Session ChatSession @relation(fields: [sessionId], references: [id], onDelete: Cascade)
// Message content
role String // "user", "assistant", "system", "tool", "function"
content String?
name String?
toolCallId String?
refusal String?
toolCalls Json? // List of tool calls for assistant messages
functionCall Json? // Deprecated but kept for compatibility
// Ordering within session
sequence Int
@@unique([sessionId, sequence])
@@index([sessionId, sequence])
}
// This model describes the Agent Graph/Flow (Multi Agent System).
model AgentGraph {
id String @default(uuid())
@@ -721,26 +812,26 @@ view StoreAgent {
storeListingVersionId String
updated_at DateTime
slug String
agent_name String
agent_video String?
agent_output_demo String?
agent_image String[]
slug String
agent_name String
agent_video String?
agent_output_demo String?
agent_image String[]
featured Boolean @default(false)
creator_username String?
creator_avatar String?
sub_heading String
description String
categories String[]
search Unsupported("tsvector")? @default(dbgenerated("''::tsvector"))
runs Int
rating Float
versions String[]
agentGraphVersions String[]
agentGraphId String
is_available Boolean @default(true)
useForOnboarding Boolean @default(false)
featured Boolean @default(false)
creator_username String?
creator_avatar String?
sub_heading String
description String
categories String[]
search Unsupported("tsvector")? @default(dbgenerated("''::tsvector"))
runs Int
rating Float
versions String[]
agentGraphVersions String[]
agentGraphId String
is_available Boolean @default(true)
useForOnboarding Boolean @default(false)
// Materialized views used (refreshed every 15 minutes via pg_cron):
// - mv_agent_run_counts - Pre-aggregated agent execution counts by agentGraphId
@@ -856,14 +947,14 @@ model StoreListingVersion {
AgentGraph AgentGraph @relation(fields: [agentGraphId, agentGraphVersion], references: [id, version])
// Content fields
name String
subHeading String
videoUrl String?
agentOutputDemoUrl String?
imageUrls String[]
description String
instructions String?
categories String[]
name String
subHeading String
videoUrl String?
agentOutputDemoUrl String?
imageUrls String[]
description String
instructions String?
categories String[]
isFeatured Boolean @default(false)
@@ -899,7 +990,6 @@ model StoreListingVersion {
// Reviews for this specific version
Reviews StoreListingReview[]
@@unique([storeListingId, version])
@@index([storeListingId, submissionStatus, isAvailable])
@@index([submissionStatus])
@@index([reviewerId])
@@ -998,16 +1088,16 @@ model OAuthApplication {
updatedAt DateTime @updatedAt
// Application metadata
name String
description String?
logoUrl String? // URL to app logo stored in GCS
clientId String @unique
clientSecret String // Hashed with Scrypt (same as API keys)
clientSecretSalt String // Salt for Scrypt hashing
name String
description String?
logoUrl String? // URL to app logo stored in GCS
clientId String @unique
clientSecret String // Hashed with Scrypt (same as API keys)
clientSecretSalt String // Salt for Scrypt hashing
// OAuth configuration
redirectUris String[] // Allowed callback URLs
grantTypes String[] @default(["authorization_code", "refresh_token"])
grantTypes String[] @default(["authorization_code", "refresh_token"])
scopes APIKeyPermission[] // Which permissions the app can request
// Application management

View File

@@ -3,13 +3,6 @@ import { withSentryConfig } from "@sentry/nextjs";
/** @type {import('next').NextConfig} */
const nextConfig = {
productionBrowserSourceMaps: true,
// Externalize OpenTelemetry packages to fix Turbopack HMR issues
serverExternalPackages: [
"@opentelemetry/instrumentation",
"@opentelemetry/sdk-node",
"import-in-the-middle",
"require-in-the-middle",
],
experimental: {
serverActions: {
bodySizeLimit: "256mb",

View File

@@ -117,7 +117,6 @@
},
"devDependencies": {
"@chromatic-com/storybook": "4.1.2",
"@opentelemetry/instrumentation": "0.209.0",
"@playwright/test": "1.56.1",
"@storybook/addon-a11y": "9.1.5",
"@storybook/addon-docs": "9.1.5",
@@ -141,7 +140,6 @@
"eslint": "8.57.1",
"eslint-config-next": "15.5.7",
"eslint-plugin-storybook": "9.1.5",
"import-in-the-middle": "2.0.2",
"msw": "2.11.6",
"msw-storybook-addon": "2.0.6",
"orval": "7.13.0",
@@ -149,7 +147,7 @@
"postcss": "8.5.6",
"prettier": "3.6.2",
"prettier-plugin-tailwindcss": "0.7.1",
"require-in-the-middle": "8.0.1",
"require-in-the-middle": "7.5.2",
"storybook": "9.1.5",
"tailwindcss": "3.4.17",
"typescript": "5.9.3"

View File

@@ -270,9 +270,6 @@ importers:
'@chromatic-com/storybook':
specifier: 4.1.2
version: 4.1.2(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))
'@opentelemetry/instrumentation':
specifier: 0.209.0
version: 0.209.0(@opentelemetry/api@1.9.0)
'@playwright/test':
specifier: 1.56.1
version: 1.56.1
@@ -342,9 +339,6 @@ importers:
eslint-plugin-storybook:
specifier: 9.1.5
version: 9.1.5(eslint@8.57.1)(storybook@9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2))(typescript@5.9.3)
import-in-the-middle:
specifier: 2.0.2
version: 2.0.2
msw:
specifier: 2.11.6
version: 2.11.6(@types/node@24.10.0)(typescript@5.9.3)
@@ -367,8 +361,8 @@ importers:
specifier: 0.7.1
version: 0.7.1(prettier@3.6.2)
require-in-the-middle:
specifier: 8.0.1
version: 8.0.1
specifier: 7.5.2
version: 7.5.2
storybook:
specifier: 9.1.5
version: 9.1.5(@testing-library/dom@10.4.1)(msw@2.11.6(@types/node@24.10.0)(typescript@5.9.3))(prettier@3.6.2)
@@ -1553,10 +1547,6 @@ packages:
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api-logs@0.209.0':
resolution: {integrity: sha512-xomnUNi7TiAGtOgs0tb54LyrjRZLu9shJGGwkcN7NgtiPYOpNnKLkRJtzZvTjD/w6knSZH9sFZcUSUovYOPg6A==}
engines: {node: '>=8.0.0'}
'@opentelemetry/api@1.9.0':
resolution: {integrity: sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==}
engines: {node: '>=8.0.0'}
@@ -1711,12 +1701,6 @@ packages:
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/instrumentation@0.209.0':
resolution: {integrity: sha512-Cwe863ojTCnFlxVuuhG7s6ODkAOzKsAEthKAcI4MDRYz1OmGWYnmSl4X2pbyS+hBxVTdvfZePfoEA01IjqcEyw==}
engines: {node: ^18.19.0 || >=20.6.0}
peerDependencies:
'@opentelemetry/api': ^1.3.0
'@opentelemetry/redis-common@0.38.2':
resolution: {integrity: sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA==}
engines: {node: ^18.19.0 || >=20.6.0}
@@ -4973,8 +4957,8 @@ packages:
resolution: {integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==}
engines: {node: '>=6'}
import-in-the-middle@2.0.2:
resolution: {integrity: sha512-qet/hkGt3EbNGVtbDfPu0BM+tCqBS8wT1SYrstPaDKoWtshsC6licOemz7DVtpBEyvDNzo8UTBf9/GwWuSDZ9w==}
import-in-the-middle@2.0.1:
resolution: {integrity: sha512-bruMpJ7xz+9jwGzrwEhWgvRrlKRYCRDBrfU+ur3FcasYXLJDxTruJ//8g2Noj+QFyRBeqbpj8Bhn4Fbw6HjvhA==}
imurmurhash@0.1.4:
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
@@ -6518,6 +6502,10 @@ packages:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
require-in-the-middle@7.5.2:
resolution: {integrity: sha512-gAZ+kLqBdHarXB64XpAe2VCjB7rIRv+mU8tfRWziHRJ5umKsIHN2tLLv6EtMw7WCdP19S0ERVMldNvxYCHnhSQ==}
engines: {node: '>=8.6.0'}
require-in-the-middle@8.0.1:
resolution: {integrity: sha512-QT7FVMXfWOYFbeRBF6nu+I6tr2Tf3u0q8RIEjNob/heKY/nh7drD/k7eeMFmSQgnTtCzLDcCu/XEnpW2wk4xCQ==}
engines: {node: '>=9.3.0 || >=8.10.0 <9.0.0'}
@@ -8732,10 +8720,6 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs@0.209.0':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api@1.9.0': {}
'@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0)':
@@ -8936,16 +8920,7 @@ snapshots:
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.208.0
import-in-the-middle: 2.0.2
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
'@opentelemetry/instrumentation@0.209.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/api-logs': 0.209.0
import-in-the-middle: 2.0.2
import-in-the-middle: 2.0.1
require-in-the-middle: 8.0.1
transitivePeerDependencies:
- supports-color
@@ -9125,7 +9100,7 @@ snapshots:
'@prisma/instrumentation@6.19.0(@opentelemetry/api@1.9.0)':
dependencies:
'@opentelemetry/api': 1.9.0
'@opentelemetry/instrumentation': 0.209.0(@opentelemetry/api@1.9.0)
'@opentelemetry/instrumentation': 0.208.0(@opentelemetry/api@1.9.0)
transitivePeerDependencies:
- supports-color
@@ -9969,7 +9944,7 @@ snapshots:
'@opentelemetry/semantic-conventions': 1.38.0
'@sentry/core': 10.27.0
'@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
import-in-the-middle: 2.0.2
import-in-the-middle: 2.0.1
transitivePeerDependencies:
- supports-color
@@ -10008,7 +9983,7 @@ snapshots:
'@sentry/core': 10.27.0
'@sentry/node-core': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/instrumentation@0.208.0(@opentelemetry/api@1.9.0))(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
'@sentry/opentelemetry': 10.27.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)
import-in-the-middle: 2.0.2
import-in-the-middle: 2.0.1
minimatch: 9.0.5
transitivePeerDependencies:
- supports-color
@@ -12817,7 +12792,7 @@ snapshots:
parent-module: 1.0.1
resolve-from: 4.0.0
import-in-the-middle@2.0.2:
import-in-the-middle@2.0.1:
dependencies:
acorn: 8.15.0
acorn-import-attributes: 1.9.5(acorn@8.15.0)
@@ -14656,6 +14631,14 @@ snapshots:
require-from-string@2.0.2: {}
require-in-the-middle@7.5.2:
dependencies:
debug: 4.4.3
module-details-from-path: 1.0.4
resolve: 1.22.11
transitivePeerDependencies:
- supports-color
require-in-the-middle@8.0.1:
dependencies:
debug: 4.4.3

View File

@@ -1,38 +1,32 @@
"use client";
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import {
Alert,
AlertDescription,
AlertTitle,
} from "@/components/molecules/Alert/Alert";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { ErrorCard } from "@/components/molecules/ErrorCard/ErrorCard";
import { cn } from "@/lib/utils";
import { PlusIcon } from "@phosphor-icons/react";
import { useEffect, useState } from "react";
import { AgentVersionChangelog } from "./components/AgentVersionChangelog";
import { AgentSettingsModal } from "./components/modals/AgentSettingsModal/AgentSettingsModal";
import { RunAgentModal } from "./components/modals/RunAgentModal/RunAgentModal";
import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
import { AgentVersionChangelog } from "./components/AgentVersionChangelog";
import { MarketplaceBanners } from "@/components/contextual/MarketplaceBanners/MarketplaceBanners";
import { PublishAgentModal } from "@/components/contextual/PublishAgentModal/PublishAgentModal";
import { AgentSettingsButton } from "./components/other/AgentSettingsButton";
import { AgentRunsLoading } from "./components/other/AgentRunsLoading";
import { EmptySchedules } from "./components/other/EmptySchedules";
import { EmptyTasks } from "./components/other/EmptyTasks";
import { EmptyTemplates } from "./components/other/EmptyTemplates";
import { EmptyTriggers } from "./components/other/EmptyTriggers";
import { MarketplaceBanners } from "./components/other/MarketplaceBanners";
import { SectionWrap } from "./components/other/SectionWrap";
import { LoadingSelectedContent } from "./components/selected-views/LoadingSelectedContent";
import { SelectedRunView } from "./components/selected-views/SelectedRunView/SelectedRunView";
import { SelectedScheduleView } from "./components/selected-views/SelectedScheduleView/SelectedScheduleView";
import { SelectedSettingsView } from "./components/selected-views/SelectedSettingsView/SelectedSettingsView";
import { SelectedTemplateView } from "./components/selected-views/SelectedTemplateView/SelectedTemplateView";
import { SelectedTriggerView } from "./components/selected-views/SelectedTriggerView/SelectedTriggerView";
import { SelectedViewLayout } from "./components/selected-views/SelectedViewLayout";
import { SidebarRunsList } from "./components/sidebar/SidebarRunsList/SidebarRunsList";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "./helpers";
import { useAgentMissingCredentials } from "./hooks/useAgentMissingCredentials";
import { useMarketplaceUpdate } from "./hooks/useMarketplaceUpdate";
import { useNewAgentLibraryView } from "./useNewAgentLibraryView";
export function NewAgentLibraryView() {
@@ -51,6 +45,7 @@ export function NewAgentLibraryView() {
handleSelectRun,
handleCountsChange,
handleClearSelectedRun,
handleSelectSettings,
onRunInitiated,
onTriggerSetup,
onScheduleCreated,
@@ -68,10 +63,6 @@ export function NewAgentLibraryView() {
} = useMarketplaceUpdate({ agent });
const [changelogOpen, setChangelogOpen] = useState(false);
const [settingsModalOpen, setSettingsModalOpen] = useState(false);
const { hasMissingCredentials, isLoading: isLoadingCredentials } =
useAgentMissingCredentials(agent);
useEffect(() => {
if (agent) {
@@ -146,33 +137,13 @@ export function NewAgentLibraryView() {
return (
<>
<div className="flex h-full flex-col">
<div className="mx-6 flex flex-col gap-4 pt-4">
<div className="flex items-center justify-between">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
{ name: agent.name, link: `/library/agents/${agentId}` },
]}
/>
<AgentSettingsModal agent={agent} />
</div>
{hasMissingCredentials && !isLoadingCredentials && (
<Alert variant="warning">
<AlertTitle>Missing credentials</AlertTitle>
<AlertDescription>
<Text variant="small" className="text-zinc-800">
This agent requires credentials that are not configured.{" "}
<button
onClick={() => setSettingsModalOpen(true)}
className="font-medium underline hover:no-underline"
>
Configure credentials
</button>{" "}
to run tasks.
</Text>
</AlertDescription>
</Alert>
)}
<div className="mx-6 pt-4">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
{ name: agent.name, link: `/library/agents/${agentId}` },
]}
/>
</div>
<div className="flex min-h-0 flex-1">
<EmptyTasks
@@ -183,13 +154,6 @@ export function NewAgentLibraryView() {
/>
</div>
</div>
{agent && (
<AgentSettingsModal
agent={agent}
controlledOpen={settingsModalOpen}
onOpenChange={setSettingsModalOpen}
/>
)}
{renderPublishAgentModal()}
{renderVersionChangelog()}
</>
@@ -200,49 +164,37 @@ export function NewAgentLibraryView() {
<>
<div className="mx-4 grid h-full grid-cols-1 gap-0 pt-3 md:ml-4 md:mr-0 md:gap-4 lg:grid-cols-[25%_70%]">
<SectionWrap className="mb-3 block">
{hasMissingCredentials && !isLoadingCredentials && (
<div className={cn("mb-4", AGENT_LIBRARY_SECTION_PADDING_X)}>
<Alert variant="warning">
<AlertTitle>Missing credentials</AlertTitle>
<AlertDescription>
<Text variant="small" className="text-zinc-800">
This agent requires credentials that are not configured.{" "}
<button
onClick={() => setSettingsModalOpen(true)}
className="font-medium underline hover:no-underline"
>
Configure credentials
</button>{" "}
to run tasks.
</Text>
</AlertDescription>
</Alert>
</div>
)}
<div
className={cn(
"border-b border-zinc-100 pb-5",
AGENT_LIBRARY_SECTION_PADDING_X,
)}
>
<RunAgentModal
triggerSlot={
<Button
variant="primary"
size="large"
className="w-full"
disabled={isTemplateLoading && activeTab === "templates"}
>
<PlusIcon size={20} /> New task
</Button>
}
agent={agent}
onRunCreated={onRunInitiated}
onScheduleCreated={onScheduleCreated}
onTriggerSetup={onTriggerSetup}
initialInputValues={activeTemplate?.inputs}
initialInputCredentials={activeTemplate?.credentials}
/>
<div className="flex items-center gap-2">
<RunAgentModal
triggerSlot={
<Button
variant="primary"
size="large"
className="flex-1"
disabled={isTemplateLoading && activeTab === "templates"}
>
<PlusIcon size={20} /> New task
</Button>
}
agent={agent}
onRunCreated={onRunInitiated}
onScheduleCreated={onScheduleCreated}
onTriggerSetup={onTriggerSetup}
initialInputValues={activeTemplate?.inputs}
initialInputCredentials={activeTemplate?.credentials}
/>
<AgentSettingsButton
agent={agent}
onSelectSettings={handleSelectSettings}
selected={activeItem === "settings"}
/>
</div>
</div>
<SidebarRunsList
@@ -256,7 +208,12 @@ export function NewAgentLibraryView() {
</SectionWrap>
{activeItem ? (
activeTab === "scheduled" ? (
activeItem === "settings" ? (
<SelectedSettingsView
agent={agent}
onClearSelectedRun={handleClearSelectedRun}
/>
) : activeTab === "scheduled" ? (
<SelectedScheduleView
agent={agent}
scheduleId={activeItem}
@@ -289,6 +246,8 @@ export function NewAgentLibraryView() {
onSelectRun={handleSelectRun}
onClearSelectedRun={handleClearSelectedRun}
banner={renderMarketplaceUpdateBanner()}
onSelectSettings={handleSelectSettings}
selectedSettings={activeItem === "settings"}
/>
)
) : sidebarLoading ? (
@@ -328,13 +287,6 @@ export function NewAgentLibraryView() {
</SelectedViewLayout>
)}
</div>
{agent && (
<AgentSettingsModal
agent={agent}
controlledOpen={settingsModalOpen}
onOpenChange={setSettingsModalOpen}
/>
)}
{renderPublishAgentModal()}
{renderVersionChangelog()}
</>

View File

@@ -4,7 +4,6 @@ import type { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import type { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { CredentialsInput } from "../CredentialsInputs/CredentialsInputs";
import { isSystemCredential } from "../CredentialsInputs/helpers";
import { RunAgentInputs } from "../RunAgentInputs/RunAgentInputs";
import { getAgentCredentialsFields, getAgentInputFields } from "./helpers";
@@ -72,7 +71,6 @@ export function AgentInputsReadOnly({
{credentialFieldEntries.map(([key, inputSubSchema]) => {
const credential = credentialInputs![key];
if (!credential) return null;
if (isSystemCredential(credential)) return null;
return (
<CredentialsInput

View File

@@ -1,131 +0,0 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Text } from "@/components/atoms/Text/Text";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
import { GearIcon } from "@phosphor-icons/react";
import { useMemo, useState } from "react";
import { useAgentSystemCredentials } from "../../../hooks/useAgentSystemCredentials";
import { SystemCredentialRow } from "../../selected-views/SelectedSettingsView/components/SystemCredentialRow";
interface Props {
agent: LibraryAgent;
controlledOpen?: boolean;
onOpenChange?: (open: boolean) => void;
}
export function AgentSettingsModal({
agent,
controlledOpen,
onOpenChange,
}: Props) {
const [internalIsOpen, setInternalIsOpen] = useState(false);
const isOpen = controlledOpen !== undefined ? controlledOpen : internalIsOpen;
function setIsOpen(open: boolean) {
if (onOpenChange) {
onOpenChange(open);
} else {
setInternalIsOpen(open);
}
}
const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } =
useAgentSafeMode(agent);
const { hasSystemCredentials, systemCredentials } =
useAgentSystemCredentials(agent);
// Only show credential fields that have system credentials
const credentialFieldsWithSystemCreds = useMemo(() => {
return systemCredentials.map((item) => ({
fieldKey: item.key,
schema: item.schema,
systemCredential: item.credential,
}));
}, [systemCredentials]);
const hasAnySettings = hasHITLBlocks || hasSystemCredentials;
return (
<Dialog
controlled={{ isOpen, set: setIsOpen }}
styling={{ maxWidth: "600px", maxHeight: "90vh" }}
title="Agent Settings"
>
{controlledOpen === undefined && (
<Dialog.Trigger>
<Button
variant="ghost"
size="small"
className="m-0 min-w-0 rounded-full p-0 px-1"
aria-label="Agent Settings"
>
<GearIcon size={18} className="text-zinc-600" />
<Text variant="small">Agent Settings</Text>
</Button>
</Dialog.Trigger>
)}
<Dialog.Content>
<div className="space-y-6">
{hasHITLBlocks && (
<div className="flex w-full flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div className="flex w-full items-start justify-between gap-4">
<div className="flex-1">
<Text variant="large-semibold">Require human approval</Text>
<Text variant="large" className="mt-1 text-zinc-900">
The agent will pause and wait for your review before
continuing
</Text>
</div>
<Switch
checked={currentSafeMode || false}
onCheckedChange={handleToggle}
disabled={isPending}
className="mt-1"
/>
</div>
</div>
)}
{hasSystemCredentials && (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div>
<Text variant="large-semibold">System Credentials</Text>
<Text variant="body" className="mt-1 text-muted-foreground">
These credentials are managed by AutoGPT and used by the agent
to access various services. You can switch to your own
credentials if preferred.
</Text>
</div>
<div className="w-full space-y-4">
{credentialFieldsWithSystemCreds.map(
({ fieldKey, schema, systemCredential }) => (
<SystemCredentialRow
key={fieldKey}
credentialKey={fieldKey}
agentId={agent.id.toString()}
schema={schema}
systemCredential={systemCredential}
/>
),
)}
</div>
</div>
)}
{!hasAnySettings && (
<div className="py-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any configurable settings.
</Text>
</div>
)}
</div>
</Dialog.Content>
</Dialog>
);
}

View File

@@ -10,10 +10,11 @@ import { toDisplayName } from "@/providers/agent-credentials/helper";
import { APIKeyCredentialsModal } from "./components/APIKeyCredentialsModal/APIKeyCredentialsModal";
import { CredentialRow } from "./components/CredentialRow/CredentialRow";
import { CredentialsSelect } from "./components/CredentialsSelect/CredentialsSelect";
import { DeleteConfirmationModal } from "./components/DeleteConfirmationModal/DeleteConfirmationModal";
import { HostScopedCredentialsModal } from "./components/HotScopedCredentialsModal/HotScopedCredentialsModal";
import { OAuthFlowWaitingModal } from "./components/OAuthWaitingModal/OAuthWaitingModal";
import { PasswordCredentialsModal } from "./components/PasswordCredentialsModal/PasswordCredentialsModal";
import { isSystemCredential } from "./helpers";
import { getCredentialDisplayName } from "./helpers";
import {
CredentialsInputState,
useCredentialsInput,
@@ -36,7 +37,6 @@ type Props = {
isOptional?: boolean;
showTitle?: boolean;
variant?: "default" | "node";
allowSystemCredentials?: boolean; // Allow system credentials (for settings only)
};
export function CredentialsInput({
@@ -50,7 +50,6 @@ export function CredentialsInput({
isOptional = false,
showTitle = true,
variant = "default",
allowSystemCredentials = false,
}: Props) {
const hookData = useCredentialsInput({
schema,
@@ -60,7 +59,6 @@ export function CredentialsInput({
onLoaded,
readOnly,
isOptional,
allowSystemCredentials,
});
if (!isLoaded(hookData)) {
@@ -81,22 +79,21 @@ export function CredentialsInput({
isHostScopedCredentialsModalOpen,
isOAuth2FlowInProgress,
oAuthPopupController,
credentialToDelete,
deleteCredentialsMutation,
actionButtonText,
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,
setHostScopedCredentialsModalOpen,
setCredentialToDelete,
handleActionButtonClick,
handleCredentialSelect,
handleDeleteCredential,
handleDeleteConfirm,
} = hookData;
const displayName = toDisplayName(provider);
const hasCredentialsToShow = credentialsToShow.length > 0;
const selectedCredentialIsSystem =
selectedCredential && isSystemCredential(selectedCredential);
if (readOnly && selectedCredentialIsSystem) {
return null;
}
return (
<div className={cn("mb-6", className)}>
@@ -140,6 +137,15 @@ export function CredentialsInput({
provider={provider}
displayName={displayName}
onSelect={() => handleCredentialSelect(credential.id)}
onDelete={() =>
handleDeleteCredential({
id: credential.id,
title: getCredentialDisplayName(
credential,
displayName,
),
})
}
readOnly={readOnly}
/>
);
@@ -223,6 +229,13 @@ export function CredentialsInput({
Error: {oAuthError}
</Text>
) : null}
<DeleteConfirmationModal
credentialToDelete={credentialToDelete}
isDeleting={deleteCredentialsMutation.isPending}
onClose={() => setCredentialToDelete(null)}
onConfirm={handleDeleteConfirm}
/>
</>
)}
</div>

View File

@@ -1,11 +1,11 @@
import { Input } from "@/components/atoms/Input/Input";
import { Button } from "@/components/atoms/Button/Button";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
Form,
FormDescription,
FormField,
} from "@/components/__legacy__/ui/form";
import { Button } from "@/components/atoms/Button/Button";
import { Input } from "@/components/atoms/Input/Input";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import {
BlockIOCredentialsSubSchema,
CredentialsMetaInput,
@@ -60,10 +60,7 @@ export function APIKeyCredentialsModal({
)}
<Form {...form}>
<form
onSubmit={form.handleSubmit(onSubmit)}
className="space-y-2 px-2"
>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-2">
<FormField
control={form.control}
name="apiKey"
@@ -73,7 +70,8 @@ export function APIKeyCredentialsModal({
id="apiKey"
label="API Key"
type="password"
placeholder="Enter API Key..."
placeholder="Enter API key..."
size="small"
hint={
schema.credentials_scopes ? (
<FormDescription>
@@ -100,7 +98,8 @@ export function APIKeyCredentialsModal({
id="title"
label="Name"
type="text"
placeholder="Enter a name for this API Key..."
placeholder="Enter a name for this API key..."
size="small"
{...field}
/>
)}
@@ -114,12 +113,13 @@ export function APIKeyCredentialsModal({
label="Expiration Date"
type="datetime-local"
placeholder="Select expiration date..."
size="small"
{...field}
/>
)}
/>
<Button type="submit" className="min-w-68">
Add API Key
<Button type="submit" size="small" className="min-w-68">
Save & use this API key
</Button>
</form>
</Form>

View File

@@ -26,7 +26,7 @@ type CredentialRowProps = {
provider: string;
displayName: string;
onSelect: () => void;
onDelete?: () => void;
onDelete: () => void;
readOnly?: boolean;
showCaret?: boolean;
asSelectTrigger?: boolean;
@@ -100,7 +100,7 @@ export function CredentialRow({
{showCaret && !asSelectTrigger && (
<CaretDown className="h-4 w-4 shrink-0 text-gray-400" />
)}
{!readOnly && !showCaret && !asSelectTrigger && onDelete && (
{!readOnly && !showCaret && !asSelectTrigger && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button

View File

@@ -65,7 +65,7 @@ export function CredentialsSelect({
>
<SelectTrigger
className={cn(
"h-auto min-h-12 w-full rounded-medium p-0 pr-4 shadow-none",
"h-auto min-h-12 w-full rounded-medium border-zinc-200 p-0 pr-4 shadow-none",
variant === "node" && "overflow-hidden",
)}
>
@@ -87,39 +87,6 @@ export function CredentialsSelect({
variant={variant}
/>
</SelectValue>
) : allowNone ? (
<SelectValue key="__none__" asChild>
<div
className={cn(
"flex items-center gap-3 rounded-medium border border-zinc-200 bg-white p-3 transition-colors",
variant === "node"
? "min-w-0 flex-1 overflow-hidden border-0 bg-transparent"
: "border-0 bg-transparent",
)}
>
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-zinc-200">
<Text
variant="body"
className="text-xs font-medium text-zinc-500"
>
</Text>
</div>
<div
className={cn(
"flex min-w-0 flex-1 flex-nowrap items-center gap-4",
variant === "node" && "overflow-hidden",
)}
>
<Text
variant="body"
className={cn("tracking-tight text-zinc-500")}
>
None (skip this credential)
</Text>
</div>
</div>
</SelectValue>
) : (
<SelectValue key="placeholder" placeholder="Select credential" />
)}

View File

@@ -100,29 +100,3 @@ export function getCredentialDisplayName(
export const OAUTH_TIMEOUT_MS = 5 * 60 * 1000;
export const MASKED_KEY_LENGTH = 30;
export function isSystemCredential(credential: {
title?: string | null;
is_system?: boolean;
}): boolean {
if (credential.is_system === true) return true;
if (!credential.title) return false;
const titleLower = credential.title.toLowerCase();
return (
titleLower.includes("system") ||
titleLower.startsWith("use credits for") ||
titleLower.includes("use credits")
);
}
export function filterSystemCredentials<
T extends { title?: string; is_system?: boolean },
>(credentials: T[]): T[] {
return credentials.filter((cred) => !isSystemCredential(cred));
}
export function getSystemCredentials<
T extends { title?: string; is_system?: boolean },
>(credentials: T[]): T[] {
return credentials.filter((cred) => isSystemCredential(cred));
}

View File

@@ -6,11 +6,9 @@ import {
CredentialsMetaInput,
} from "@/lib/autogpt-server-api/types";
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import {
filterSystemCredentials,
getActionButtonText,
getSystemCredentials,
OAUTH_TIMEOUT_MS,
OAuthPopupResultMessage,
} from "./helpers";
@@ -25,7 +23,6 @@ type Params = {
onLoaded?: (loaded: boolean) => void;
readOnly?: boolean;
isOptional?: boolean;
allowSystemCredentials?: boolean; // Allow system credentials (for settings only)
};
export function useCredentialsInput({
@@ -36,7 +33,6 @@ export function useCredentialsInput({
onLoaded,
readOnly = false,
isOptional = false,
allowSystemCredentials = false,
}: Params) {
const [isAPICredentialsModalOpen, setAPICredentialsModalOpen] =
useState(false);
@@ -58,7 +54,6 @@ export function useCredentialsInput({
const api = useBackendAPI();
const queryClient = useQueryClient();
const credentials = useCredentials(schema, siblingInputs);
const hasAttemptedAutoSelect = useRef(false);
const deleteCredentialsMutation = useDeleteV1DeleteCredentials({
mutation: {
@@ -87,22 +82,13 @@ export function useCredentialsInput({
useEffect(() => {
if (readOnly) return;
if (!credentials || !("savedCredentials" in credentials)) return;
const availableCreds = allowSystemCredentials
? credentials.savedCredentials
: filterSystemCredentials(credentials.savedCredentials);
if (
selectedCredential &&
!availableCreds.some((c) => c.id === selectedCredential.id)
!credentials.savedCredentials.some((c) => c.id === selectedCredential.id)
) {
onSelectCredential(undefined);
}
}, [
credentials,
selectedCredential,
onSelectCredential,
readOnly,
allowSystemCredentials,
]);
}, [credentials, selectedCredential, onSelectCredential, readOnly]);
// The available credential, if there is only one
const singleCredential = useMemo(() => {
@@ -110,111 +96,24 @@ export function useCredentialsInput({
return null;
}
const credsToUse = allowSystemCredentials
? credentials.savedCredentials
: filterSystemCredentials(credentials.savedCredentials);
return credsToUse.length === 1 ? credsToUse[0] : null;
}, [credentials, allowSystemCredentials]);
return credentials.savedCredentials.length === 1
? credentials.savedCredentials[0]
: null;
}, [credentials]);
// Auto-select the one available credential
// Prioritize system credentials if available
// For system credentials, always auto-select even if optional (they should be used by default)
// Auto-select the one available credential (only if not optional)
useEffect(() => {
if (readOnly) return;
if (!credentials || !("savedCredentials" in credentials)) return;
// Early return if already selected to prevent infinite loops
const currentSelectedId = selectedCredential?.id;
if (currentSelectedId) {
hasAttemptedAutoSelect.current = true;
return;
}
// If selectedCredential is explicitly undefined and isOptional is true,
// don't auto-select - this could mean "None" was explicitly selected
// The parent component should handle setting the initial value
if (selectedCredential === undefined && isOptional) {
// Mark as attempted to prevent auto-selection when "None" is a valid choice
hasAttemptedAutoSelect.current = true;
return;
}
// Only attempt auto-selection once per credential load
if (hasAttemptedAutoSelect.current) return;
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
const savedCreds = credentials.savedCredentials;
const systemCreds = getSystemCredentials(savedCreds);
// Filter system credentials by type and scopes (same logic as useCredentials)
const matchingSystemCreds = systemCreds.filter((cred) => {
// Check type match
if (!supportedTypes.includes(cred.type)) {
return false;
}
// For OAuth2 credentials, check scopes
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) {
return false;
}
}
return true;
});
// First, try to auto-select system credential if available
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
const credProvider = credentials.provider;
hasAttemptedAutoSelect.current = true;
onSelectCredential({
id: systemCred.id,
type: systemCred.type,
provider: credProvider,
title: (systemCred as any).title,
});
return;
}
// Otherwise, auto-select single credential if there's only one (and not optional)
if (!isOptional && singleCredential) {
hasAttemptedAutoSelect.current = true;
if (isOptional) return; // Don't auto-select when credential is optional
if (singleCredential && !selectedCredential) {
onSelectCredential(singleCredential);
}
}, [
singleCredential?.id, // Only depend on the ID, not the whole object
selectedCredential?.id, // Only depend on the ID, not the whole object
singleCredential,
selectedCredential,
onSelectCredential,
readOnly,
isOptional,
credentials,
schema.credentials_types,
schema.credentials_scopes,
// Note: onSelectCredential removed from deps to prevent infinite loops
// It should be stable, but if it's not, the ref will prevent multiple calls
]);
// Reset the ref when credentials change significantly
useEffect(() => {
if (credentials && "savedCredentials" in credentials) {
hasAttemptedAutoSelect.current = false;
}
}, [
credentials && "savedCredentials" in credentials
? credentials.savedCredentials.length
: 0,
credentials && "savedCredentials" in credentials
? credentials.provider
: null,
]);
if (
@@ -238,11 +137,6 @@ export function useCredentialsInput({
oAuthCallback,
} = credentials;
// Filter system credentials unless explicitly allowed (for settings)
const filteredCredentials = allowSystemCredentials
? savedCredentials
: filterSystemCredentials(savedCredentials);
async function handleOAuthLogin() {
setOAuthError(null);
const { login_url, state_token } = await api.oAuthLogin(
@@ -397,7 +291,7 @@ export function useCredentialsInput({
supportsOAuth2,
supportsUserPassword,
supportsHostScoped,
credentialsToShow: filteredCredentials,
credentialsToShow: savedCredentials,
selectedCredential,
oAuthError,
isAPICredentialsModalOpen,
@@ -412,7 +306,7 @@ export function useCredentialsInput({
supportsApiKey,
supportsUserPassword,
supportsHostScoped,
filteredCredentials.length > 0,
savedCredentials.length > 0,
),
setAPICredentialsModalOpen,
setUserPasswordCredentialsModalOpen,

View File

@@ -12,7 +12,7 @@ import {
TooltipTrigger,
} from "@/components/atoms/Tooltip/BaseTooltip";
import { Dialog } from "@/components/molecules/Dialog/Dialog";
import { useEffect, useRef, useState } from "react";
import { useState } from "react";
import { ScheduleAgentModal } from "../ScheduleAgentModal/ScheduleAgentModal";
import { ModalHeader } from "./components/ModalHeader/ModalHeader";
import { ModalRunSection } from "./components/ModalRunSection/ModalRunSection";
@@ -82,8 +82,6 @@ export function RunAgentModal({
});
const [isScheduleModalOpen, setIsScheduleModalOpen] = useState(false);
const [hasOverflow, setHasOverflow] = useState(false);
const contentRef = useRef<HTMLDivElement>(null);
const hasAnySetupFields =
Object.keys(agentInputFields || {}).length > 0 ||
@@ -91,43 +89,6 @@ export function RunAgentModal({
const isTriggerRunType = defaultRunType.includes("trigger");
useEffect(() => {
if (!isOpen) return;
function checkOverflow() {
if (!contentRef.current) return;
const scrollableParent = contentRef.current
.closest("[data-dialog-content]")
?.querySelector('[class*="overflow-y-auto"]');
if (scrollableParent) {
setHasOverflow(
scrollableParent.scrollHeight > scrollableParent.clientHeight,
);
}
}
const timeoutId = setTimeout(checkOverflow, 100);
const resizeObserver = new ResizeObserver(checkOverflow);
if (contentRef.current) {
const scrollableParent = contentRef.current
.closest("[data-dialog-content]")
?.querySelector('[class*="overflow-y-auto"]');
if (scrollableParent) {
resizeObserver.observe(scrollableParent);
}
}
return () => {
clearTimeout(timeoutId);
resizeObserver.disconnect();
};
}, [
isOpen,
hasAnySetupFields,
agentInputFields,
agentCredentialsInputFields,
]);
function handleInputChange(key: string, value: string) {
setInputValues((prev) => ({
...prev,
@@ -173,97 +134,91 @@ export function RunAgentModal({
>
<Dialog.Trigger>{triggerSlot}</Dialog.Trigger>
<Dialog.Content>
<div ref={contentRef} className="flex min-h-full flex-col">
<div className="flex-1">
{/* Header */}
<ModalHeader agent={agent} />
{/* Header */}
<ModalHeader agent={agent} />
{/* Content */}
{hasAnySetupFields ? (
<div className="mt-10 pb-32">
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
}}
>
<ModalRunSection />
</RunAgentModalContextProvider>
</div>
) : null}
{/* Content */}
{hasAnySetupFields ? (
<div className="mt-10">
<RunAgentModalContextProvider
value={{
agent,
defaultRunType,
presetName,
setPresetName,
presetDescription,
setPresetDescription,
inputValues,
setInputValue: handleInputChange,
agentInputFields,
inputCredentials,
setInputCredentialsValue: handleCredentialsChange,
agentCredentialsInputFields,
}}
>
<ModalRunSection />
</RunAgentModalContextProvider>
</div>
) : null}
<Dialog.Footer
className={`sticky bottom-0 z-10 bg-white pt-4 ${
hasOverflow
? "border-t border-neutral-100 shadow-[0_-2px_8px_rgba(0,0,0,0.04)]"
: ""
}`}
>
<div className="flex items-center justify-end gap-3">
{isTriggerRunType ? null : !allRequiredInputsAreSet ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Please set up all required inputs and credentials
before scheduling
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={isExecuting || isSettingUpTrigger}
>
Schedule Task
</Button>
)}
<RunActions
defaultRunType={defaultRunType}
onRun={handleRun}
isExecuting={isExecuting}
isSettingUpTrigger={isSettingUpTrigger}
isRunReady={allRequiredInputsAreSet}
/>
</div>
<ScheduleAgentModal
isOpen={isScheduleModalOpen}
onClose={handleCloseScheduleModal}
agent={agent}
inputValues={inputValues}
inputCredentials={inputCredentials}
onScheduleCreated={handleScheduleCreated}
<Dialog.Footer className="mt-6 bg-white pt-4">
<div className="flex items-center justify-end gap-3">
{isTriggerRunType ? null : !allRequiredInputsAreSet ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<span>
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
<p>
Please set up all required inputs and credentials before
scheduling
</p>
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : (
<Button
variant="secondary"
onClick={handleOpenScheduleModal}
disabled={
isExecuting ||
isSettingUpTrigger ||
!allRequiredInputsAreSet
}
>
Schedule Task
</Button>
)}
<RunActions
defaultRunType={defaultRunType}
onRun={handleRun}
isExecuting={isExecuting}
isSettingUpTrigger={isSettingUpTrigger}
isRunReady={allRequiredInputsAreSet}
/>
</Dialog.Footer>
</div>
</div>
<ScheduleAgentModal
isOpen={isScheduleModalOpen}
onClose={handleCloseScheduleModal}
agent={agent}
inputValues={inputValues}
inputCredentials={inputCredentials}
onScheduleCreated={handleScheduleCreated}
/>
</Dialog.Footer>
</Dialog.Content>
</Dialog>
</>

View File

@@ -1,16 +1,6 @@
import { CredentialsInput } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/modals/CredentialsInputs/CredentialsInputs";
import { Input } from "@/components/atoms/Input/Input";
import { InformationTooltip } from "@/components/molecules/InformationTooltip/InformationTooltip";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { useContext, useMemo } from "react";
import {
NONE_CREDENTIAL_MARKER,
useAgentCredentialPreferencesStore,
} from "../../../../../stores/agentCredentialPreferencesStore";
import {
filterSystemCredentials,
isSystemCredential,
} from "../../../CredentialsInputs/helpers";
import { RunAgentInputs } from "../../../RunAgentInputs/RunAgentInputs";
import { useRunAgentModalContext } from "../../context";
import { ModalSection } from "../ModalSection/ModalSection";
@@ -32,44 +22,8 @@ export function ModalRunSection() {
agentCredentialsInputFields,
} = useRunAgentModalContext();
const allProviders = useContext(CredentialsProvidersContext);
const store = useAgentCredentialPreferencesStore();
const inputFields = Object.entries(agentInputFields || {});
// Only show credential fields that have user credentials (NOT system credentials)
// System credentials should only be shown in settings, not in run modal
const credentialFields = useMemo(() => {
if (!allProviders || !agentCredentialsInputFields) return [];
return Object.entries(agentCredentialsInputFields).filter(
([_key, schema]) => {
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
// Check if any provider has user credentials (NOT system credentials)
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
const matchingUserCreds = userCreds.filter((cred: { type: string }) =>
supportedTypes.includes(cred.type),
);
// If there are user credentials available, show this field
if (matchingUserCreds.length > 0) {
return true;
}
}
// Hide the field if only system credentials exist (or no credentials at all)
return false;
},
);
}, [agentCredentialsInputFields, allProviders]);
const credentialFields = Object.entries(agentCredentialsInputFields || {});
// Get the list of required credentials from the schema
const requiredCredentials = new Set(
@@ -144,113 +98,22 @@ export function ModalRunSection() {
subtitle="These are the credentials the agent will use to perform this task"
>
<div className="space-y-6">
{credentialFields
.map(([key, inputSubSchema]) => {
const selectedCred = inputCredentials?.[key];
// Check if the selected credential is a system credential
// First check the credential object itself, then look it up in providers
let isSystemCredSelected = false;
if (selectedCred) {
// Check if credential object has is_system or title indicates system credential
isSystemCredSelected = isSystemCredential(
selectedCred as { title?: string; is_system?: boolean },
);
// If not detected by title/is_system, check by looking up in providers
if (
!isSystemCredSelected &&
selectedCred.id &&
allProviders
) {
const providerNames =
inputSubSchema.credentials_provider || [];
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = providerData.savedCredentials.filter(
(cred: any) => cred.is_system === true,
);
if (
systemCreds.some(
(cred: any) => cred.id === selectedCred.id,
)
) {
isSystemCredSelected = true;
break;
}
}
{Object.entries(agentCredentialsInputFields || {}).map(
([key, inputSubSchema]) => (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
}
// If a system credential is selected, check if there are user credentials available
// If not, hide this field entirely (it will still be used for execution)
if (isSystemCredSelected) {
const providerNames =
inputSubSchema.credentials_provider || [];
const supportedTypes = inputSubSchema.credentials_types || [];
const hasUserCreds = providerNames.some(
(providerName: string) => {
const providerData = allProviders?.[providerName];
if (!providerData) return false;
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
return userCreds.some((cred: { type: string }) =>
supportedTypes.includes(cred.type),
);
},
);
// If no user credentials available, hide the field completely
if (!hasUserCreds) {
return null;
selectedCredentials={inputCredentials?.[key]}
onSelectCredentials={(value) =>
setInputCredentialsValue(key, value)
}
}
// If a system credential is selected but user creds exist, don't show it in the UI
// (it will still be used for execution, but user can select a user credential instead)
const credToDisplay = isSystemCredSelected
? undefined
: selectedCred;
return (
<CredentialsInput
key={key}
schema={
{ ...inputSubSchema, discriminator: undefined } as any
}
selectedCredentials={credToDisplay}
onSelectCredentials={(value) => {
// When user selects a credential, update the state and save to preferences
setInputCredentialsValue(key, value);
// Save to preferences store
if (value === undefined) {
store.setCredentialPreference(
agent.id.toString(),
key,
NONE_CREDENTIAL_MARKER,
);
} else if (value === null) {
store.setCredentialPreference(
agent.id.toString(),
key,
null,
);
} else {
store.setCredentialPreference(
agent.id.toString(),
key,
value,
);
}
}}
siblingInputs={inputValues}
isOptional={!requiredCredentials.has(key)}
/>
);
})
.filter(Boolean)}
siblingInputs={inputValues}
isOptional={!requiredCredentials.has(key)}
/>
),
)}
</div>
</ModalSection>
) : null}

View File

@@ -11,25 +11,9 @@ import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { LibraryAgentPreset } from "@/app/api/__generated__/models/libraryAgentPreset";
import { useToast } from "@/components/molecules/Toast/use-toast";
import { isEmpty } from "@/lib/utils";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { analytics } from "@/services/analytics";
import { useQueryClient } from "@tanstack/react-query";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import {
NONE_CREDENTIAL_MARKER,
useAgentCredentialPreferencesStore,
} from "../../../stores/agentCredentialPreferencesStore";
import {
filterSystemCredentials,
getSystemCredentials,
} from "../CredentialsInputs/helpers";
import { useCallback, useEffect, useMemo, useState } from "react";
import { showExecutionErrorToast } from "./errorHelpers";
export type RunVariant =
@@ -58,10 +42,8 @@ export function useAgentRunModal(
const [inputCredentials, setInputCredentials] = useState<Record<string, any>>(
callbacks?.initialInputCredentials || {},
);
const [presetName, setPresetName] = useState<string>("");
const [presetDescription, setPresetDescription] = useState<string>("");
const hasInitializedSystemCreds = useRef(false);
// Determine the default run type based on agent capabilities
const defaultRunType: RunVariant = agent.trigger_setup_info
@@ -76,198 +58,6 @@ export function useAgentRunModal(
setInputCredentials(callbacks?.initialInputCredentials || {});
}, [callbacks?.initialInputValues, callbacks?.initialInputCredentials]);
const allProviders = useContext(CredentialsProvidersContext);
const store = useAgentCredentialPreferencesStore();
// Initialize credentials from saved preferences or default system credentials
// This ensures credentials are used even when the field is not displayed
useEffect(() => {
if (!allProviders || !agent.credentials_input_schema?.properties) return;
if (callbacks?.initialInputCredentials) {
hasInitializedSystemCreds.current = true;
return; // Don't override if initial credentials provided
}
if (hasInitializedSystemCreds.current) return; // Already initialized
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
// Use functional update to get current state and avoid stale closures
setInputCredentials((currentCreds) => {
const credsToAdd: Record<string, any> = {};
for (const [key, schema] of Object.entries(properties)) {
// Skip if already set
if (currentCreds[key]) continue;
// First, check if user has a saved preference
const savedPreference = store.getCredentialPreference(
agent.id.toString(),
key,
);
// Check if "None" was explicitly selected (special marker)
if (savedPreference === NONE_CREDENTIAL_MARKER) {
// User explicitly selected "None" - don't add any credential
continue;
}
if (savedPreference) {
credsToAdd[key] = savedPreference;
continue;
}
// Otherwise, find default system credentials for this field
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(
providerData.savedCredentials,
);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
// For OAuth2 credentials, check scopes
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
// If there's exactly one system credential, use it as default
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
credsToAdd[key] = {
id: systemCred.id,
type: systemCred.type,
provider: providerName,
title: systemCred.title,
};
break; // Use first matching provider
}
}
}
// Only update if we found credentials to add
if (Object.keys(credsToAdd).length > 0) {
hasInitializedSystemCreds.current = true;
return {
...currentCreds,
...credsToAdd,
};
}
return currentCreds; // No changes
});
}, [
allProviders,
agent.credentials_input_schema,
agent.id,
store,
callbacks?.initialInputCredentials,
]);
// Sync credentials with preferences store when modal opens
useEffect(() => {
if (!isOpen || !allProviders || !agent.credentials_input_schema?.properties)
return;
if (callbacks?.initialInputCredentials) return; // Don't override if initial credentials provided
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
setInputCredentials((currentCreds) => {
const updatedCreds: Record<string, any> = { ...currentCreds };
for (const [key, schema] of Object.entries(properties)) {
const savedPreference = store.getCredentialPreference(
agent.id.toString(),
key,
);
if (savedPreference === NONE_CREDENTIAL_MARKER) {
// User explicitly selected "None" - remove from credentials
delete updatedCreds[key];
} else if (savedPreference) {
// User has a saved preference - use it
updatedCreds[key] = savedPreference;
} else if (!updatedCreds[key]) {
// No preference and no current credential - try to find default system credential
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(
providerData.savedCredentials,
);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
if (matchingSystemCreds.length === 1) {
const systemCred = matchingSystemCreds[0];
updatedCreds[key] = {
id: systemCred.id,
type: systemCred.type,
provider: providerName,
title: systemCred.title,
};
break;
}
}
}
}
return updatedCreds;
});
}, [
isOpen,
agent.id,
agent.credentials_input_schema,
allProviders,
store,
callbacks?.initialInputCredentials,
]);
// Reset initialization flag when modal closes/opens or agent changes
useEffect(() => {
hasInitializedSystemCreds.current = false;
}, [isOpen, agent.graph_id]);
// API mutations
const executeGraphMutation = usePostV1ExecuteGraphAgent({
mutation: {
@@ -379,70 +169,15 @@ export function useAgentRunModal(
(agent.credentials_input_schema?.required as string[]) || [],
);
// Filter out credential fields that only have system credentials available
// System credentials should not be required in the run modal
// Also check if user has a saved preference (including NONE_MARKER)
const requiredCredentialsToCheck = [...requiredCredentials].filter(
(key) => {
// Check if user has a saved preference first
const savedPreference = store.getCredentialPreference(
agent.id.toString(),
key,
);
// If "None" was explicitly selected, don't require it
if (savedPreference === NONE_CREDENTIAL_MARKER) {
return false;
}
// If user has a saved preference, it should be checked
if (savedPreference) {
return true;
}
const schema = agentCredentialsInputFields[key];
if (!schema || !allProviders) return true; // If we can't check, include it
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
// Check if any provider has non-system credentials available
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
const matchingUserCreds = userCreds.filter((cred) =>
supportedTypes.includes(cred.type),
);
// If there are user credentials available, this field should be checked
if (matchingUserCreds.length > 0) {
return true;
}
}
// If only system credentials are available, exclude from required check
return false;
},
);
// Check if required credentials have valid id (not just key existence)
// A credential is valid only if it has an id field set
const missing = requiredCredentialsToCheck.filter((key) => {
const missing = [...requiredCredentials].filter((key) => {
const cred = inputCredentials[key];
return !cred || !cred.id;
});
return [missing.length === 0, missing];
}, [
agent.credentials_input_schema,
agentCredentialsInputFields,
inputCredentials,
allProviders,
agent.id,
store,
]);
}, [agent.credentials_input_schema, inputCredentials]);
const credentialsRequired = useMemo(
() => Object.keys(agentCredentialsInputFields || {}).length > 0,

View File

@@ -1,17 +1,37 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
import { GearIcon } from "@phosphor-icons/react";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
interface Props {
agent: LibraryAgent;
onSelectSettings: () => void;
selected?: boolean;
}
export function AgentSettingsButton({
agent,
onSelectSettings,
selected,
}: Props) {
const { hasHITLBlocks } = useAgentSafeMode(agent);
if (!hasHITLBlocks) {
return null;
}
export function AgentSettingsButton() {
return (
<Button
variant="ghost"
variant={selected ? "secondary" : "ghost"}
size="small"
className="m-0 min-w-0 rounded-full p-0 px-1"
onClick={onSelectSettings}
aria-label="Agent Settings"
>
<GearIcon size={18} className="text-zinc-600" />
<Text variant="small">Agent Settings</Text>
<GearIcon
size={18}
className={selected ? "text-zinc-900" : "text-zinc-600"}
/>
</Button>
);
}

View File

@@ -1,5 +1,3 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
export function EmptySchedules() {

View File

@@ -20,7 +20,6 @@ import { useQueryClient } from "@tanstack/react-query";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useAgentMissingCredentials } from "../../hooks/useAgentMissingCredentials";
import { RunAgentModal } from "../modals/RunAgentModal/RunAgentModal";
import { RunDetailCard } from "../selected-views/RunDetailCard/RunDetailCard";
import { EmptyTasksIllustration } from "./EmptyTasksIllustration";
@@ -45,7 +44,6 @@ export function EmptyTasks({
const [isDeletingAgent, setIsDeletingAgent] = useState(false);
const { mutateAsync: deleteAgent } = useDeleteV2DeleteLibraryAgent();
const { hasMissingCredentials } = useAgentMissingCredentials(agent);
async function handleDeleteAgent() {
if (!agent.id) return;
@@ -126,7 +124,6 @@ export function EmptyTasks({
variant="primary"
size="large"
className="inline-flex w-[19.75rem]"
disabled={hasMissingCredentials}
>
Setup your task
</Button>

View File

@@ -1,5 +1,3 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
export function EmptyTemplates() {

View File

@@ -1,5 +1,3 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
export function EmptyTriggers() {

View File

@@ -1,5 +1,3 @@
"use client";
import { cn } from "@/lib/utils";
type Props = {

View File

@@ -1,16 +1,22 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Skeleton } from "@/components/__legacy__/ui/skeleton";
import { cn } from "@/lib/utils";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
import { SelectedViewLayout } from "./SelectedViewLayout";
interface Props {
agent: LibraryAgent;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function LoadingSelectedContent(props: Props) {
return (
<SelectedViewLayout agent={props.agent}>
<SelectedViewLayout
agent={props.agent}
onSelectSettings={props.onSelectSettings}
selectedSettings={props.selectedSettings}
>
<div
className={cn("flex flex-col gap-4", AGENT_LIBRARY_SECTION_PADDING_X)}
>

View File

@@ -33,6 +33,8 @@ interface Props {
onSelectRun?: (id: string) => void;
onClearSelectedRun?: () => void;
banner?: React.ReactNode;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function SelectedRunView({
@@ -41,6 +43,8 @@ export function SelectedRunView({
onSelectRun,
onClearSelectedRun,
banner,
onSelectSettings,
selectedSettings,
}: Props) {
const { run, preset, isLoading, responseError, httpError } =
useSelectedRunView(agent.graph_id, runId);
@@ -80,7 +84,12 @@ export function SelectedRunView({
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout agent={agent} banner={banner}>
<SelectedViewLayout
agent={agent}
banner={banner}
onSelectSettings={onSelectSettings}
selectedSettings={selectedSettings}
>
<div className="flex flex-col gap-4">
<RunDetailHeader agent={agent} run={run} />

View File

@@ -21,6 +21,8 @@ interface Props {
scheduleId: string;
onClearSelectedRun?: () => void;
banner?: React.ReactNode;
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function SelectedScheduleView({
@@ -28,6 +30,8 @@ export function SelectedScheduleView({
scheduleId,
onClearSelectedRun,
banner,
onSelectSettings,
selectedSettings,
}: Props) {
const { schedule, isLoading, error } = useSelectedScheduleView(
agent.graph_id,
@@ -72,7 +76,12 @@ export function SelectedScheduleView({
return (
<div className="flex h-full w-full gap-4">
<div className="flex min-h-0 min-w-0 flex-1 flex-col">
<SelectedViewLayout agent={agent} banner={banner}>
<SelectedViewLayout
agent={agent}
banner={banner}
onSelectSettings={onSelectSettings}
selectedSettings={selectedSettings}
>
<div className="flex flex-col gap-4">
<div className="flex w-full flex-col gap-0">
<RunDetailHeader

View File

@@ -1,12 +1,11 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Button } from "@/components/atoms/Button/Button";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Text } from "@/components/atoms/Text/Text";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
import { Switch } from "@/components/atoms/Switch/Switch";
import { Button } from "@/components/atoms/Button/Button";
import { ArrowLeftIcon } from "@phosphor-icons/react";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
import { useAgentSafeMode } from "@/hooks/useAgentSafeMode";
import { SelectedViewLayout } from "../SelectedViewLayout";
import { SystemCredentialsSection } from "./components/SystemCredentialsSection";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../../helpers";
interface Props {
agent: LibraryAgent;
@@ -17,12 +16,8 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
const { currentSafeMode, isPending, hasHITLBlocks, handleToggle } =
useAgentSafeMode(agent);
const hasCredentialsSchema =
agent.credentials_input_schema &&
Object.keys(agent.credentials_input_schema.properties || {}).length > 0;
return (
<SelectedViewLayout agent={agent}>
<SelectedViewLayout agent={agent} onSelectSettings={() => {}}>
<div className="flex flex-col gap-4">
<div
className={`${AGENT_LIBRARY_SECTION_PADDING_X} mb-8 flex items-center gap-3`}
@@ -38,8 +33,15 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
<Text variant="h2">Agent Settings</Text>
</div>
<div className={`${AGENT_LIBRARY_SECTION_PADDING_X} space-y-6`}>
{hasHITLBlocks && (
<div className={AGENT_LIBRARY_SECTION_PADDING_X}>
{!hasHITLBlocks ? (
<div className="rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any human-in-the-loop blocks, so
there are no settings to configure.
</Text>
</div>
) : (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div className="flex w-full items-start justify-between gap-4">
<div className="flex-1">
@@ -58,16 +60,6 @@ export function SelectedSettingsView({ agent, onClearSelectedRun }: Props) {
</div>
</div>
)}
{hasCredentialsSchema && <SystemCredentialsSection agent={agent} />}
{!hasHITLBlocks && !hasCredentialsSchema && (
<div className="rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="body" className="text-muted-foreground">
This agent doesn&apos;t have any configurable settings.
</Text>
</div>
)}
</div>
</div>
</SelectedViewLayout>

View File

@@ -1,99 +0,0 @@
"use client";
import { Text } from "@/components/atoms/Text/Text";
import { CredentialsMetaResponse } from "@/lib/autogpt-server-api/types";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { useEffect, useState } from "react";
import { CredentialsInput } from "../../../../components/modals/CredentialsInputs/CredentialsInputs";
import {
NONE_CREDENTIAL_MARKER,
useAgentCredentialPreferencesStore,
} from "../../../../stores/agentCredentialPreferencesStore";
interface Props {
credentialKey: string;
agentId: string;
schema: any;
systemCredential: CredentialsMetaResponse;
}
export function SystemCredentialRow({
credentialKey,
agentId,
schema,
systemCredential,
}: Props) {
const store = useAgentCredentialPreferencesStore();
// Initialize with saved preference or default to system credential
const savedPreference = store.getCredentialPreference(agentId, credentialKey);
const defaultCredential = {
id: systemCredential.id,
type: systemCredential.type,
provider: systemCredential.provider,
title: systemCredential.title,
};
// If saved preference is the NONE marker, use undefined (which CredentialsInput interprets as "None")
// Otherwise use saved preference or default
const [selectedCredential, setSelectedCredential] = useState<any>(
savedPreference === NONE_CREDENTIAL_MARKER
? undefined
: savedPreference || defaultCredential,
);
// Update when preference changes externally
useEffect(() => {
const preference = store.getCredentialPreference(agentId, credentialKey);
if (preference === NONE_CREDENTIAL_MARKER) {
setSelectedCredential(undefined);
} else if (preference) {
setSelectedCredential(preference);
} else {
setSelectedCredential(defaultCredential);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [credentialKey, agentId]);
const providerName = schema.credentials_provider?.[0] || "";
const displayName = toDisplayName(providerName);
function handleSelectCredentials(value: any) {
setSelectedCredential(value);
// Save preference:
// - undefined = explicitly selected "None" (save NONE_CREDENTIAL_MARKER)
// - null = use default system credential (fallback behavior, save null)
// - credential object = use this specific credential
if (value === undefined) {
// User explicitly selected "None" - save special marker
store.setCredentialPreference(
agentId,
credentialKey,
NONE_CREDENTIAL_MARKER,
);
} else if (value === null) {
// User cleared selection - use default system credential
store.setCredentialPreference(agentId, credentialKey, null);
} else {
// User selected a credential
store.setCredentialPreference(agentId, credentialKey, value);
}
}
return (
<div className="rounded-lg border border-zinc-100 bg-zinc-50/50 px-4 pb-2 pt-4">
<Text variant="body-medium" className="mb-2 ml-2">
{displayName}
</Text>
<CredentialsInput
schema={{ ...schema, discriminator: undefined }}
selectedCredentials={selectedCredential}
onSelectCredentials={handleSelectCredentials}
showTitle={false}
isOptional
allowSystemCredentials={true}
/>
</div>
);
}

View File

@@ -1,63 +0,0 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Text } from "@/components/atoms/Text/Text";
import { useAgentSystemCredentials } from "../../../../hooks/useAgentSystemCredentials";
import { SystemCredentialRow } from "./SystemCredentialRow";
interface Props {
agent: LibraryAgent;
}
export function SystemCredentialsSection({ agent }: Props) {
const { hasSystemCredentials, systemCredentials, isLoading } =
useAgentSystemCredentials(agent);
if (isLoading) {
return (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<Text variant="large-semibold">System Credentials</Text>
<Text variant="body" className="text-muted-foreground">
Loading credentials...
</Text>
</div>
);
}
if (!hasSystemCredentials) return null;
// Group by credential field key (from schema) to show one row per field
const credentialsByField = systemCredentials.reduce(
(acc, item) => {
if (!acc[item.key]) {
acc[item.key] = item;
}
return acc;
},
{} as Record<string, (typeof systemCredentials)[0]>,
);
return (
<div className="flex w-full max-w-2xl flex-col items-start gap-4 rounded-xl border border-zinc-100 bg-white p-6">
<div>
<Text variant="large-semibold">System Credentials</Text>
<Text variant="body" className="mt-1 text-muted-foreground">
These credentials are managed by AutoGPT and used by the agent to
access various services. You can switch to your own credentials if
preferred.
</Text>
</div>
<div className="w-full space-y-4">
{Object.entries(credentialsByField).map(([fieldKey, item]) => (
<SystemCredentialRow
key={fieldKey}
credentialKey={fieldKey}
agentId={agent.id.toString()}
schema={item.schema}
systemCredential={item.credential}
/>
))}
</div>
</div>
);
}

View File

@@ -1,7 +1,7 @@
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { Breadcrumbs } from "@/components/molecules/Breadcrumbs/Breadcrumbs";
import { AgentSettingsButton } from "@/app/(platform)/library/agents/[id]/components/NewAgentLibraryView/components/other/AgentSettingsButton";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { AGENT_LIBRARY_SECTION_PADDING_X } from "../../helpers";
import { AgentSettingsModal } from "../modals/AgentSettingsModal/AgentSettingsModal";
import { SectionWrap } from "../other/SectionWrap";
interface Props {
@@ -9,6 +9,8 @@ interface Props {
children: React.ReactNode;
banner?: React.ReactNode;
additionalBreadcrumb?: { name: string; link?: string };
onSelectSettings?: () => void;
selectedSettings?: boolean;
}
export function SelectedViewLayout(props: Props) {
@@ -17,8 +19,8 @@ export function SelectedViewLayout(props: Props) {
<div
className={`${AGENT_LIBRARY_SECTION_PADDING_X} flex-shrink-0 border-b border-zinc-100 pb-0 lg:pb-4`}
>
{props.banner}
<div className="relative flex w-full items-center justify-between">
{props.banner && <div className="mb-4">{props.banner}</div>}
<div className="relative flex w-fit items-center gap-2">
<Breadcrumbs
items={[
{ name: "My Library", link: "/library" },
@@ -31,9 +33,15 @@ export function SelectedViewLayout(props: Props) {
: []),
]}
/>
<div className="absolute right-0">
<AgentSettingsModal agent={props.agent} />
</div>
{props.agent && props.onSelectSettings && (
<div className="absolute -right-8">
<AgentSettingsButton
agent={props.agent}
onSelectSettings={props.onSelectSettings}
selected={props.selectedSettings}
/>
</div>
)}
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto overflow-x-visible">

View File

@@ -1,109 +0,0 @@
"use client";
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { storage } from "@/services/storage/local-storage";
import { useCallback, useEffect, useState } from "react";
// Special marker to indicate "None" was explicitly selected
export const NONE_CREDENTIAL_MARKER = { __none__: true } as const;
type AgentCredentialPreferences = Record<
string,
CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER
>;
const STORAGE_KEY_PREFIX = "agent_credential_prefs_";
function getStorageKey(agentId: string): string {
return `${STORAGE_KEY_PREFIX}${agentId}`;
}
function loadPreferences(agentId: string): AgentCredentialPreferences {
const key = getStorageKey(agentId);
const stored = storage.get(key as any);
if (!stored) return {};
try {
const parsed = JSON.parse(stored);
// Convert serialized NONE markers back to the constant
const result: AgentCredentialPreferences = {};
for (const [key, value] of Object.entries(parsed)) {
if (
value &&
typeof value === "object" &&
"__none__" in value &&
(value as any).__none__ === true
) {
result[key] = NONE_CREDENTIAL_MARKER;
} else {
result[key] = value as CredentialsMetaInput | null;
}
}
return result;
} catch {
return {};
}
}
function savePreferences(
agentId: string,
preferences: AgentCredentialPreferences,
): void {
const key = getStorageKey(agentId);
storage.set(key as any, JSON.stringify(preferences));
}
export function useAgentCredentialPreferences(agentId: string) {
const [preferences, setPreferences] = useState<AgentCredentialPreferences>(
() => loadPreferences(agentId),
);
useEffect(() => {
const loaded = loadPreferences(agentId);
setPreferences(loaded);
}, [agentId]);
const setCredentialPreference = useCallback(
(
credentialKey: string,
credential: CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER,
) => {
setPreferences((prev) => {
const updated = {
...prev,
[credentialKey]: credential,
};
savePreferences(agentId, updated);
return updated;
});
},
[agentId],
);
const getCredentialPreference = useCallback(
(
credentialKey: string,
): CredentialsMetaInput | null | typeof NONE_CREDENTIAL_MARKER => {
return preferences[credentialKey] ?? null;
},
[preferences],
);
const clearPreference = useCallback(
(credentialKey: string) => {
setPreferences((prev) => {
const updated = { ...prev };
delete updated[credentialKey];
savePreferences(agentId, updated);
return updated;
});
},
[agentId],
);
return {
preferences,
setCredentialPreference,
getCredentialPreference,
clearPreference,
};
}

View File

@@ -1,105 +0,0 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { CredentialsProvidersContext } from "@/providers/agent-credentials/credentials-provider";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { useContext, useMemo } from "react";
import { getSystemCredentials } from "../components/modals/CredentialsInputs/helpers";
/**
* Hook to check if an agent is missing required SYSTEM credentials.
* This is only used to block "New Task" buttons.
* User credential validation is handled separately in RunAgentModal.
*/
export function useAgentMissingCredentials(
agent: LibraryAgent | null | undefined,
) {
const allProviders = useContext(CredentialsProvidersContext);
const result = useMemo(() => {
if (
!agent ||
!agent.id ||
!allProviders ||
!agent.credentials_input_schema?.properties
) {
return {
hasMissingCredentials: false,
missingCredentials: [],
isLoading: !allProviders || !agent,
};
}
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
const requiredCredentials = new Set(
(agent.credentials_input_schema.required as string[]) || [],
);
const missingCredentials: Array<{
key: string;
providerDisplayName: string;
}> = [];
for (const [key, schema] of Object.entries(properties)) {
const isRequired = requiredCredentials.has(key);
if (!isRequired) continue; // Only check required credentials
const providerNames = schema.credentials_provider || [];
const supportedTypes = schema.credentials_types || [];
const requiredScopes = schema.credentials_scopes;
let hasSystemCredential = false;
// Check if any provider has a system credential available
for (const providerName of providerNames) {
const providerData = allProviders[providerName];
if (!providerData) continue;
const systemCreds = getSystemCredentials(providerData.savedCredentials);
const matchingSystemCreds = systemCreds.filter((cred) => {
if (!supportedTypes.includes(cred.type)) return false;
if (
cred.type === "oauth2" &&
requiredScopes &&
requiredScopes.length > 0
) {
const grantedScopes = new Set(cred.scopes || []);
const hasAllRequiredScopes = new Set(requiredScopes).isSubsetOf(
grantedScopes,
);
if (!hasAllRequiredScopes) return false;
}
return true;
});
// If there's a system credential available, it's not missing
if (matchingSystemCreds.length > 0) {
hasSystemCredential = true;
break;
}
}
// If no system credential available, mark as missing
if (!hasSystemCredential) {
const providerName = providerNames[0] || "";
missingCredentials.push({
key,
providerDisplayName: toDisplayName(providerName),
});
}
}
return {
hasMissingCredentials: missingCredentials.length > 0,
missingCredentials,
isLoading: false,
};
}, [allProviders, agent?.credentials_input_schema, agent?.id]);
return result;
}

View File

@@ -1,130 +0,0 @@
"use client";
import { LibraryAgent } from "@/app/api/__generated__/models/libraryAgent";
import { CredentialsMetaResponse } from "@/lib/autogpt-server-api/types";
import {
CredentialsProviderData,
CredentialsProvidersContext,
} from "@/providers/agent-credentials/credentials-provider";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { useContext, useMemo } from "react";
import {
filterSystemCredentials,
getSystemCredentials,
} from "../components/modals/CredentialsInputs/helpers";
interface SystemCredentialInfo {
key: string;
provider: string;
schema: any;
credential: CredentialsMetaResponse;
}
interface MissingCredentialInfo {
key: string;
provider: string;
providerDisplayName: string;
}
interface UseAgentSystemCredentialsResult {
hasSystemCredentials: boolean;
systemCredentials: SystemCredentialInfo[];
hasMissingSystemCredentials: boolean;
missingSystemCredentials: MissingCredentialInfo[];
isLoading: boolean;
}
export function useAgentSystemCredentials(
agent: LibraryAgent,
): UseAgentSystemCredentialsResult {
const allProviders = useContext(CredentialsProvidersContext);
const result = useMemo(() => {
const empty = {
hasSystemCredentials: false,
systemCredentials: [],
hasMissingSystemCredentials: false,
missingSystemCredentials: [],
isLoading: false,
};
if (!agent.credentials_input_schema?.properties) return empty;
if (!allProviders) return { ...empty, isLoading: true };
const properties = agent.credentials_input_schema.properties as Record<
string,
any
>;
const requiredCredentials = new Set(
(agent.credentials_input_schema.required as string[]) || [],
);
const systemCredentials: SystemCredentialInfo[] = [];
const missingSystemCredentials: MissingCredentialInfo[] = [];
for (const [key, schema] of Object.entries(properties)) {
const providerNames = schema.credentials_provider || [];
const isRequired = requiredCredentials.has(key);
const supportedTypes = schema.credentials_types || [];
for (const providerName of providerNames) {
const providerData: CredentialsProviderData | undefined =
allProviders[providerName];
if (!providerData) {
// Provider not loaded yet - don't mark as missing, wait for load
continue;
}
// Check for system credentials - now backend always returns them with is_system: true
const systemCreds = getSystemCredentials(providerData.savedCredentials);
const userCreds = filterSystemCredentials(
providerData.savedCredentials,
);
const matchingSystemCreds = systemCreds.filter((cred) =>
supportedTypes.includes(cred.type),
);
const matchingUserCreds = userCreds.filter((cred) =>
supportedTypes.includes(cred.type),
);
// Add system credentials if they exist (even if not configured, backend returns them)
for (const cred of matchingSystemCreds) {
systemCredentials.push({
key,
provider: providerName,
schema,
credential: cred,
});
}
// Only mark as missing if it's required AND there are NO credentials available
// (neither system nor user). This is a true "missing" state.
// Note: We don't block based on this anymore since the run modal
// has its own validation (allRequiredInputsAreSet)
if (
isRequired &&
matchingSystemCreds.length === 0 &&
matchingUserCreds.length === 0
) {
missingSystemCredentials.push({
key,
provider: providerName,
providerDisplayName: toDisplayName(providerName),
});
}
}
}
return {
hasSystemCredentials: systemCredentials.length > 0,
systemCredentials,
hasMissingSystemCredentials: missingSystemCredentials.length > 0,
missingSystemCredentials,
isLoading: false,
};
}, [agent.credentials_input_schema, allProviders]);
return result;
}

View File

@@ -1,135 +0,0 @@
import { CredentialsMetaInput } from "@/lib/autogpt-server-api/types";
import { storage } from "@/services/storage/local-storage";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
// Special marker to indicate "None" was explicitly selected
export const NONE_CREDENTIAL_MARKER = { __none__: true } as const;
type CredentialPreference =
| CredentialsMetaInput
| null
| typeof NONE_CREDENTIAL_MARKER;
type AgentCredentialPreferences = Record<string, CredentialPreference>;
interface AgentCredentialPreferencesStore {
preferences: Record<string, AgentCredentialPreferences>; // agentId -> preferences
setCredentialPreference: (
agentId: string,
credentialKey: string,
credential: CredentialPreference,
) => void;
getCredentialPreference: (
agentId: string,
credentialKey: string,
) => CredentialPreference;
clearPreference: (agentId: string, credentialKey: string) => void;
}
const STORAGE_KEY = "agent_credential_preferences";
// Custom storage adapter for localStorage
const customStorage = {
getItem: (name: string): string | null => {
return storage.get(name as any) || null;
},
setItem: (name: string, value: string): void => {
storage.set(name as any, value);
},
removeItem: (name: string): void => {
storage.clean(name as any);
},
};
export const useAgentCredentialPreferencesStore =
create<AgentCredentialPreferencesStore>()(
persist(
(set, get) => ({
preferences: {},
setCredentialPreference: (agentId, credentialKey, credential) => {
set((state) => {
const agentPrefs = state.preferences[agentId] || {};
const updated = {
...state.preferences,
[agentId]: {
...agentPrefs,
[credentialKey]: credential,
},
};
return { preferences: updated };
});
},
getCredentialPreference: (agentId, credentialKey) => {
const state = get();
const pref = state.preferences[agentId]?.[credentialKey];
// Convert serialized NONE marker back to constant
if (
pref &&
typeof pref === "object" &&
"__none__" in pref &&
(pref as any).__none__ === true &&
pref !== NONE_CREDENTIAL_MARKER
) {
return NONE_CREDENTIAL_MARKER;
}
return pref ?? null;
},
clearPreference: (agentId, credentialKey) => {
set((state) => {
const agentPrefs = state.preferences[agentId] || {};
const updated = { ...agentPrefs };
delete updated[credentialKey];
return {
preferences: {
...state.preferences,
[agentId]: updated,
},
};
});
},
}),
{
name: STORAGE_KEY,
storage: createJSONStorage(() => customStorage),
// Transform on rehydrate to convert NONE markers
onRehydrateStorage: () => (state, error) => {
if (error || !state) {
console.error("Failed to rehydrate credential preferences:", error);
return;
}
// Convert serialized NONE markers back to constant
const converted: Record<string, AgentCredentialPreferences> = {};
for (const [agentId, prefs] of Object.entries(
state.preferences || {},
)) {
const convertedPrefs: AgentCredentialPreferences = {};
for (const [key, value] of Object.entries(prefs)) {
if (
value &&
typeof value === "object" &&
"__none__" in value &&
(value as any).__none__ === true &&
value !== NONE_CREDENTIAL_MARKER
) {
convertedPrefs[key] = NONE_CREDENTIAL_MARKER;
} else {
convertedPrefs[key] = value as CredentialPreference;
}
}
converted[agentId] = convertedPrefs;
}
// Update state with converted preferences
if (
Object.keys(converted).length > 0 ||
Object.keys(state.preferences || {}).length > 0
) {
state.preferences = converted;
}
},
},
),
);

View File

@@ -940,11 +940,67 @@
}
},
"/api/chat/sessions": {
"get": {
"tags": ["v2", "chat", "chat"],
"summary": "List Sessions",
"description": "List chat sessions for the authenticated user.\n\nReturns a paginated list of chat sessions belonging to the current user,\nordered by most recently updated.\n\nArgs:\n user_id: The authenticated user's ID.\n limit: Maximum number of sessions to return (1-100).\n offset: Number of sessions to skip for pagination.\n\nReturns:\n ListSessionsResponse: List of session summaries and total count.",
"operationId": "getV2ListSessions",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "limit",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"maximum": 100,
"minimum": 1,
"default": 50,
"title": "Limit"
}
},
{
"name": "offset",
"in": "query",
"required": false,
"schema": {
"type": "integer",
"minimum": 0,
"default": 0,
"title": "Offset"
}
}
],
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ListSessionsResponse"
}
}
}
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
},
"post": {
"tags": ["v2", "chat", "chat"],
"summary": "Create Session",
"description": "Create a new chat session.\n\nInitiates a new chat session for either an authenticated or anonymous user.\n\nArgs:\n user_id: The optional authenticated user ID parsed from the JWT. If missing, creates an anonymous session.\n\nReturns:\n CreateSessionResponse: Details of the created session.",
"operationId": "postV2CreateSession",
"security": [{ "HTTPBearerJWT": [] }],
"responses": {
"200": {
"description": "Successful Response",
@@ -959,8 +1015,7 @@
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
}
},
"security": [{ "HTTPBearerJWT": [] }]
}
}
},
"/api/chat/sessions/{session_id}": {
@@ -1048,9 +1103,9 @@
"/api/chat/sessions/{session_id}/stream": {
"get": {
"tags": ["v2", "chat", "chat"],
"summary": "Stream Chat",
"description": "Stream chat responses for a session.\n\nStreams the AI/completion responses in real time over Server-Sent Events (SSE), including:\n - Text fragments as they are generated\n - Tool call UI elements (if invoked)\n - Tool execution results\n\nArgs:\n session_id: The chat session identifier to associate with the streamed messages.\n message: The user's new message to process.\n user_id: Optional authenticated user ID.\n is_user_message: Whether the message is a user message.\nReturns:\n StreamingResponse: SSE-formatted response chunks.",
"operationId": "getV2StreamChat",
"summary": "Stream Chat Get",
"description": "Stream chat responses for a session (GET - legacy endpoint).\n\nStreams the AI/completion responses in real time over Server-Sent Events (SSE), including:\n - Text fragments as they are generated\n - Tool call UI elements (if invoked)\n - Tool execution results\n\nArgs:\n session_id: The chat session identifier to associate with the streamed messages.\n message: The user's new message to process.\n user_id: Optional authenticated user ID.\n is_user_message: Whether the message is a user message.\nReturns:\n StreamingResponse: SSE-formatted response chunks.",
"operationId": "getV2StreamChatGet",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
@@ -1098,6 +1153,46 @@
}
}
}
},
"post": {
"tags": ["v2", "chat", "chat"],
"summary": "Stream Chat Post",
"description": "Stream chat responses for a session (POST with context support).\n\nStreams the AI/completion responses in real time over Server-Sent Events (SSE), including:\n - Text fragments as they are generated\n - Tool call UI elements (if invoked)\n - Tool execution results\n\nArgs:\n session_id: The chat session identifier to associate with the streamed messages.\n request: Request body containing message, is_user_message, and optional context.\n user_id: Optional authenticated user ID.\nReturns:\n StreamingResponse: SSE-formatted response chunks.",
"operationId": "postV2StreamChatPost",
"security": [{ "HTTPBearerJWT": [] }],
"parameters": [
{
"name": "session_id",
"in": "path",
"required": true,
"schema": { "type": "string", "title": "Session Id" }
}
],
"requestBody": {
"required": true,
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/StreamChatRequest" }
}
}
},
"responses": {
"200": {
"description": "Successful Response",
"content": { "application/json": { "schema": {} } }
},
"401": {
"$ref": "#/components/responses/HTTP401NotAuthenticatedError"
},
"422": {
"description": "Validation Error",
"content": {
"application/json": {
"schema": { "$ref": "#/components/schemas/HTTPValidationError" }
}
}
}
}
}
},
"/api/credits": {
@@ -6792,12 +6887,6 @@
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Host",
"description": "Host pattern for host-scoped credentials"
},
"is_system": {
"type": "boolean",
"title": "Is System",
"description": "Whether this is a system-managed credential",
"default": false
}
},
"type": "object",
@@ -8025,6 +8114,20 @@
"required": ["source_id", "sink_id", "source_name", "sink_name"],
"title": "Link"
},
"ListSessionsResponse": {
"properties": {
"sessions": {
"items": { "$ref": "#/components/schemas/SessionSummaryResponse" },
"type": "array",
"title": "Sessions"
},
"total": { "type": "integer", "title": "Total" }
},
"type": "object",
"required": ["sessions", "total"],
"title": "ListSessionsResponse",
"description": "Response model for listing chat sessions."
},
"LogRawMetricRequest": {
"properties": {
"metric_name": {
@@ -9354,6 +9457,21 @@
"title": "SessionDetailResponse",
"description": "Response model providing complete details for a chat session, including messages."
},
"SessionSummaryResponse": {
"properties": {
"id": { "type": "string", "title": "Id" },
"created_at": { "type": "string", "title": "Created At" },
"updated_at": { "type": "string", "title": "Updated At" },
"title": {
"anyOf": [{ "type": "string" }, { "type": "null" }],
"title": "Title"
}
},
"type": "object",
"required": ["id", "created_at", "updated_at"],
"title": "SessionSummaryResponse",
"description": "Response model for a session summary (without messages)."
},
"SetGraphActiveVersion": {
"properties": {
"active_graph_version": {
@@ -9905,6 +10023,30 @@
"required": ["submissions", "pagination"],
"title": "StoreSubmissionsResponse"
},
"StreamChatRequest": {
"properties": {
"message": { "type": "string", "title": "Message" },
"is_user_message": {
"type": "boolean",
"title": "Is User Message",
"default": true
},
"context": {
"anyOf": [
{
"additionalProperties": { "type": "string" },
"type": "object"
},
{ "type": "null" }
],
"title": "Context"
}
},
"type": "object",
"required": ["message"],
"title": "StreamChatRequest",
"description": "Request model for streaming chat with optional context."
},
"SubmissionStatus": {
"type": "string",
"enum": ["DRAFT", "PENDING", "APPROVED", "REJECTED"],

View File

@@ -20,7 +20,6 @@ export function Button(props: ButtonProps) {
rightIcon,
children,
as = "button",
asChild: _asChild, // Destructure to prevent passing to DOM
...restProps
} = props;

View File

@@ -3,7 +3,7 @@
import { Button } from "@/components/atoms/Button/Button";
import { Text } from "@/components/atoms/Text/Text";
interface Props {
interface MarketplaceBannersProps {
hasUpdate?: boolean;
latestVersion?: number;
hasUnpublishedChanges?: boolean;
@@ -21,7 +21,7 @@ export function MarketplaceBanners({
isUpdating,
onUpdate,
onPublish,
}: Props) {
}: MarketplaceBannersProps) {
const renderUpdateBanner = () => {
if (hasUpdate && latestVersion) {
return (

View File

@@ -49,12 +49,7 @@ export function DrawerWrap({
>
{title ? (
<Drawer.Title className={drawerStyles.title}>{title}</Drawer.Title>
) : (
<span className="sr-only">
{/* Title is required for a11y compliance even if not displayed so screen readers can announce it */}
<Drawer.Title>{title}</Drawer.Title>
</span>
)}
) : null}
{!isForceOpen ? (
title ? (

View File

@@ -593,7 +593,6 @@ export type CredentialsMetaResponse = {
scopes?: Array<string>;
username?: string;
host?: string;
is_system?: boolean;
};
/* Mirror of backend/server/integrations/router.py:CredentialsDeletionResponse */

View File

@@ -1,4 +1,5 @@
import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
import { createContext, useCallback, useEffect, useState } from "react";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import {
APIKeyCredentials,
CredentialsDeleteNeedConfirmationResponse,
@@ -9,9 +10,8 @@ import {
UserPasswordCredentials,
} from "@/lib/autogpt-server-api";
import { useBackendAPI } from "@/lib/autogpt-server-api/context";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import { useToastOnFail } from "@/components/molecules/Toast/use-toast";
import { toDisplayName } from "@/providers/agent-credentials/helper";
import { createContext, useCallback, useEffect, useState } from "react";
type APIKeyCredentialsCreatable = Omit<
APIKeyCredentials,
@@ -72,8 +72,6 @@ export default function CredentialsProvider({
const api = useBackendAPI();
const onFailToast = useToastOnFail();
console.log("providers", providers);
const addCredentials = useCallback(
(
provider: CredentialsProviderName,
@@ -220,7 +218,17 @@ export default function CredentialsProvider({
[api, onFailToast],
);
const loadCredentials = useCallback(() => {
// Fetch provider names on mount
useEffect(() => {
api
.listProviders()
.then((names) => {
setProviderNames(names);
})
.catch(onFailToast("load provider names"));
}, [api, onFailToast]);
useEffect(() => {
if (!isLoggedIn || providerNames.length === 0) {
if (isLoggedIn == false) setProviders({});
return;
@@ -280,20 +288,6 @@ export default function CredentialsProvider({
onFailToast,
]);
// Fetch provider names on mount
useEffect(() => {
api
.listProviders()
.then((names) => {
setProviderNames(names);
})
.catch(onFailToast("Load provider names"));
}, [api, onFailToast]);
useEffect(() => {
loadCredentials();
}, [loadCredentials]);
return (
<CredentialsProvidersContext.Provider value={providers}>
{children}