Compare commits

..

1 Commits

Author SHA1 Message Date
0ubbe
5a3308074f Update frontend build based on commit 9538992eaf 2026-01-29 11:14:57 +00:00
10 changed files with 125 additions and 329 deletions

View File

@@ -1883,127 +1883,84 @@ async def _generate_llm_continuation(
This is called by background tasks to continue the conversation
after a tool result is saved. The response is saved to the database
so users see it when they refresh or poll.
Includes retry logic with exponential backoff for transient API errors.
"""
retry_count = 0
max_retries = MAX_RETRIES
last_error: Exception | None = None
try:
# Load fresh session from DB (bypass cache to get the updated tool result)
await invalidate_session_cache(session_id)
session = await get_chat_session(session_id, user_id)
if not session:
logger.error(f"Session {session_id} not found for LLM continuation")
return
while retry_count <= max_retries:
try:
# Load fresh session from DB (bypass cache to get the updated tool result)
await invalidate_session_cache(session_id)
session = await get_chat_session(session_id, user_id)
if not session:
logger.error(f"Session {session_id} not found for LLM continuation")
# Build system prompt
system_prompt, _ = await _build_system_prompt(user_id)
# Build messages in OpenAI format
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
# Build extra_body for tracing
extra_body: dict[str, Any] = {
"posthogProperties": {
"environment": settings.config.app_env.value,
},
}
if user_id:
extra_body["user"] = user_id[:128]
extra_body["posthogDistinctId"] = user_id
if session_id:
extra_body["session_id"] = session_id[:128]
# Make non-streaming LLM call (no tools - just text response)
from typing import cast
from openai.types.chat import ChatCompletionMessageParam
# No tools parameter = text-only response (no tool calls)
response = await client.chat.completions.create(
model=config.model,
messages=cast(list[ChatCompletionMessageParam], messages),
extra_body=extra_body,
)
if response.choices and response.choices[0].message.content:
assistant_content = response.choices[0].message.content
# Reload session from DB to avoid race condition with user messages
# that may have been sent while we were generating the LLM response
fresh_session = await get_chat_session(session_id, user_id)
if not fresh_session:
logger.error(
f"Session {session_id} disappeared during LLM continuation"
)
return
# Build system prompt
system_prompt, _ = await _build_system_prompt(user_id)
# Build messages in OpenAI format
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
# Build extra_body for tracing
extra_body: dict[str, Any] = {
"posthogProperties": {
"environment": settings.config.app_env.value,
},
}
if user_id:
extra_body["user"] = user_id[:128]
extra_body["posthogDistinctId"] = user_id
if session_id:
extra_body["session_id"] = session_id[:128]
from typing import cast
from openai.types.chat import ChatCompletionMessageParam
# Include tools with tool_choice="none" to allow the provider to validate
# tool interactions in the message history without allowing new tool calls.
# Some providers (especially Anthropic via OpenRouter) require the tools
# schema to be present when the conversation contains tool_use/tool_result.
response = await client.chat.completions.create(
model=config.model,
messages=cast(list[ChatCompletionMessageParam], messages),
tools=tools,
tool_choice="none",
extra_body=extra_body,
# Save assistant message to database
assistant_message = ChatMessage(
role="assistant",
content=assistant_content,
)
fresh_session.messages.append(assistant_message)
if response.choices and response.choices[0].message.content:
assistant_content = response.choices[0].message.content
# Save to database (not cache) to persist the response
await upsert_chat_session(fresh_session)
# Reload session from DB to avoid race condition with user messages
# that may have been sent while we were generating the LLM response.
# Invalidate cache first to ensure we get fresh data from the database.
await invalidate_session_cache(session_id)
fresh_session = await get_chat_session(session_id, user_id)
if not fresh_session:
logger.error(
f"Session {session_id} disappeared during LLM continuation"
)
return
# Invalidate cache so next poll/refresh gets fresh data
await invalidate_session_cache(session_id)
# Save assistant message to database
assistant_message = ChatMessage(
role="assistant",
content=assistant_content,
)
fresh_session.messages.append(assistant_message)
logger.info(
f"Generated LLM continuation for session {session_id}, "
f"response length: {len(assistant_content)}"
)
else:
logger.warning(f"LLM continuation returned empty response for {session_id}")
# Save to database (not cache) to persist the response
await upsert_chat_session(fresh_session)
# Invalidate cache so next poll/refresh gets fresh data
await invalidate_session_cache(session_id)
logger.info(
f"Generated LLM continuation for session {session_id}, "
f"response length: {len(assistant_content)}"
)
return # Success - exit the retry loop
else:
logger.warning(
f"LLM continuation returned empty response for {session_id}"
)
return # Empty response is not retryable
except Exception as e:
last_error = e
if _is_retryable_error(e) and retry_count < max_retries:
retry_count += 1
delay = min(
BASE_DELAY_SECONDS * (2 ** (retry_count - 1)),
MAX_DELAY_SECONDS,
)
logger.warning(
f"Retryable error in LLM continuation for {session_id}: {e!s}. "
f"Retrying in {delay:.1f}s (attempt {retry_count}/{max_retries})"
)
await asyncio.sleep(delay)
continue
else:
# Non-retryable error or max retries exceeded
logger.error(
f"Failed to generate LLM continuation for {session_id}: {e!s}",
exc_info=True,
)
break
# If we get here, all retries failed
if last_error:
logger.error(
f"LLM continuation failed after {retry_count} retries for {session_id}. "
f"Last error: {last_error!s}"
)
except Exception as e:
logger.error(f"Failed to generate LLM continuation: {e}", exc_info=True)

View File

@@ -1,13 +1,10 @@
"""Shared agent search functionality for find_agent and find_library_agent tools."""
import asyncio
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.data import graph as graph_db
from backend.data.graph import GraphModel
from backend.util.exceptions import DatabaseError, NotFoundError
from .models import (
@@ -17,7 +14,6 @@ from .models import (
NoResultsResponse,
ToolResponseBase,
)
from .utils import fetch_graph_from_store_slug
logger = logging.getLogger(__name__)
@@ -58,28 +54,7 @@ async def search_agents(
if source == "marketplace":
logger.info(f"Searching marketplace for: {query}")
results = await store_db.get_store_agents(search_query=query, page_size=5)
# Fetch all graphs in parallel for better performance
async def fetch_marketplace_graph(
creator: str, slug: str
) -> GraphModel | None:
try:
graph, _ = await fetch_graph_from_store_slug(creator, slug)
return graph
except Exception as e:
logger.warning(
f"Failed to fetch input schema for {creator}/{slug}: {e}"
)
return None
graphs = await asyncio.gather(
*(
fetch_marketplace_graph(agent.creator, agent.slug)
for agent in results.agents
)
)
for agent, graph in zip(results.agents, graphs):
for agent in results.agents:
agents.append(
AgentInfo(
id=f"{agent.creator}/{agent.slug}",
@@ -92,7 +67,6 @@ async def search_agents(
rating=agent.rating,
runs=agent.runs,
is_featured=False,
inputs=graph.input_schema if graph else None,
)
)
else: # library
@@ -102,32 +76,7 @@ async def search_agents(
search_term=query,
page_size=10,
)
# Fetch all graphs in parallel for better performance
# (list_library_agents doesn't include nodes for performance)
async def fetch_library_graph(
graph_id: str, graph_version: int
) -> GraphModel | None:
try:
return await graph_db.get_graph(
graph_id=graph_id,
version=graph_version,
user_id=user_id,
)
except Exception as e:
logger.warning(
f"Failed to fetch input schema for graph {graph_id}: {e}"
)
return None
graphs = await asyncio.gather(
*(
fetch_library_graph(agent.graph_id, agent.graph_version)
for agent in results.agents
)
)
for agent, graph in zip(results.agents, graphs):
for agent in results.agents:
agents.append(
AgentInfo(
id=agent.id,
@@ -141,7 +90,6 @@ async def search_agents(
has_external_trigger=agent.has_external_trigger,
new_output=agent.new_output,
graph_id=agent.graph_id,
inputs=graph.input_schema if graph else None,
)
)
logger.info(f"Found {len(agents)} agents in {source}")

View File

@@ -32,8 +32,6 @@ class ResponseType(str, Enum):
OPERATION_STARTED = "operation_started"
OPERATION_PENDING = "operation_pending"
OPERATION_IN_PROGRESS = "operation_in_progress"
# Input validation
INPUT_VALIDATION_ERROR = "input_validation_error"
# Base response model
@@ -64,10 +62,6 @@ class AgentInfo(BaseModel):
has_external_trigger: bool | None = None
new_output: bool | None = None
graph_id: str | None = None
inputs: dict[str, Any] | None = Field(
default=None,
description="Input schema for the agent, including field names, types, and defaults",
)
class AgentsFoundResponse(ToolResponseBase):
@@ -194,20 +188,6 @@ class ErrorResponse(ToolResponseBase):
details: dict[str, Any] | None = None
class InputValidationErrorResponse(ToolResponseBase):
"""Response when run_agent receives unknown input fields."""
type: ResponseType = ResponseType.INPUT_VALIDATION_ERROR
unrecognized_fields: list[str] = Field(
description="List of input field names that were not recognized"
)
inputs: dict[str, Any] = Field(
description="The agent's valid input schema for reference"
)
graph_id: str | None = None
graph_version: int | None = None
# Agent output models
class ExecutionOutputInfo(BaseModel):
"""Summary of a single execution's outputs."""

View File

@@ -30,7 +30,6 @@ from .models import (
ErrorResponse,
ExecutionOptions,
ExecutionStartedResponse,
InputValidationErrorResponse,
SetupInfo,
SetupRequirementsResponse,
ToolResponseBase,
@@ -274,22 +273,6 @@ class RunAgentTool(BaseTool):
input_properties = graph.input_schema.get("properties", {})
required_fields = set(graph.input_schema.get("required", []))
provided_inputs = set(params.inputs.keys())
valid_fields = set(input_properties.keys())
# Check for unknown input fields
unrecognized_fields = provided_inputs - valid_fields
if unrecognized_fields:
return InputValidationErrorResponse(
message=(
f"Unknown input field(s) provided: {', '.join(sorted(unrecognized_fields))}. "
f"Agent was not executed. Please use the correct field names from the schema."
),
session_id=session_id,
unrecognized_fields=sorted(unrecognized_fields),
inputs=graph.input_schema,
graph_id=graph.id,
graph_version=graph.version,
)
# If agent has inputs but none were provided AND use_defaults is not set,
# always show what's available first so user can decide

View File

@@ -402,42 +402,3 @@ async def test_run_agent_schedule_without_name(setup_test_data):
# Should return error about missing schedule_name
assert result_data.get("type") == "error"
assert "schedule_name" in result_data["message"].lower()
@pytest.mark.asyncio(loop_scope="session")
async def test_run_agent_rejects_unknown_input_fields(setup_test_data):
"""Test that run_agent returns input_validation_error for unknown input fields."""
user = setup_test_data["user"]
store_submission = setup_test_data["store_submission"]
tool = RunAgentTool()
agent_marketplace_id = f"{user.email.split('@')[0]}/{store_submission.slug}"
session = make_session(user_id=user.id)
# Execute with unknown input field names
response = await tool.execute(
user_id=user.id,
session_id=str(uuid.uuid4()),
tool_call_id=str(uuid.uuid4()),
username_agent_slug=agent_marketplace_id,
inputs={
"unknown_field": "some value",
"another_unknown": "another value",
},
session=session,
)
assert response is not None
assert hasattr(response, "output")
assert isinstance(response.output, str)
result_data = orjson.loads(response.output)
# Should return input_validation_error type with unrecognized fields
assert result_data.get("type") == "input_validation_error"
assert "unrecognized_fields" in result_data
assert set(result_data["unrecognized_fields"]) == {
"another_unknown",
"unknown_field",
}
assert "inputs" in result_data # Contains the valid schema
assert "Agent was not executed" in result_data["message"]

View File

@@ -4,8 +4,6 @@ import logging
from collections import defaultdict
from typing import Any
from pydantic_core import PydanticUndefined
from backend.api.features.chat.model import ChatSession
from backend.data.block import get_block
from backend.data.execution import ExecutionContext
@@ -75,22 +73,15 @@ class RunBlockTool(BaseTool):
self,
user_id: str,
block: Any,
input_data: dict[str, Any] | None = None,
) -> tuple[dict[str, CredentialsMetaInput], list[CredentialsMetaInput]]:
"""
Check if user has required credentials for a block.
Args:
user_id: User ID
block: Block to check credentials for
input_data: Input data for the block (used to determine provider via discriminator)
Returns:
tuple[matched_credentials, missing_credentials]
"""
matched_credentials: dict[str, CredentialsMetaInput] = {}
missing_credentials: list[CredentialsMetaInput] = []
input_data = input_data or {}
# Get credential field info from block's input schema
credentials_fields_info = block.input_schema.get_credentials_fields_info()
@@ -103,33 +94,14 @@ class RunBlockTool(BaseTool):
available_creds = await creds_manager.store.get_all_creds(user_id)
for field_name, field_info in credentials_fields_info.items():
effective_field_info = field_info
if field_info.discriminator and field_info.discriminator_mapping:
# Get discriminator from input, falling back to schema default
discriminator_value = input_data.get(field_info.discriminator)
if discriminator_value is None:
field = block.input_schema.model_fields.get(
field_info.discriminator
)
if field and field.default is not PydanticUndefined:
discriminator_value = field.default
if (
discriminator_value
and discriminator_value in field_info.discriminator_mapping
):
effective_field_info = field_info.discriminate(discriminator_value)
logger.debug(
f"Discriminated provider for {field_name}: "
f"{discriminator_value} -> {effective_field_info.provider}"
)
# field_info.provider is a frozenset of acceptable providers
# field_info.supported_types is a frozenset of acceptable types
matching_cred = next(
(
cred
for cred in available_creds
if cred.provider in effective_field_info.provider
and cred.type in effective_field_info.supported_types
if cred.provider in field_info.provider
and cred.type in field_info.supported_types
),
None,
)
@@ -143,8 +115,8 @@ class RunBlockTool(BaseTool):
)
else:
# Create a placeholder for the missing credential
provider = next(iter(effective_field_info.provider), "unknown")
cred_type = next(iter(effective_field_info.supported_types), "api_key")
provider = next(iter(field_info.provider), "unknown")
cred_type = next(iter(field_info.supported_types), "api_key")
missing_credentials.append(
CredentialsMetaInput(
id=field_name,
@@ -212,9 +184,10 @@ class RunBlockTool(BaseTool):
logger.info(f"Executing block {block.name} ({block_id}) for user {user_id}")
# Check credentials
creds_manager = IntegrationCredentialsManager()
matched_credentials, missing_credentials = await self._check_block_credentials(
user_id, block, input_data
user_id, block
)
if missing_credentials:

View File

@@ -1,15 +1,14 @@
"use client";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
import { FeatureFlagRedirect } from "@/services/feature-flags/FeatureFlagRedirect";
import { Flag } from "@/services/feature-flags/use-get-flag";
export default function Page() {
const router = useRouter();
useEffect(() => {
router.replace("/copilot");
}, [router]);
return <LoadingSpinner size="large" cover />;
return (
<FeatureFlagRedirect
flag={Flag.CHAT}
whenEnabled="/copilot"
whenDisabled="/library"
/>
);
}

View File

@@ -1,6 +1,5 @@
"use client";
import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner";
import { useSupabase } from "@/lib/supabase/hooks/useSupabase";
import * as Sentry from "@sentry/nextjs";
import { LDProvider } from "launchdarkly-react-client-sdk";
@@ -16,9 +15,7 @@ export function LaunchDarklyProvider({ children }: { children: ReactNode }) {
const clientId = environment.getLaunchDarklyClientId();
const context = useMemo(() => {
if (isUserLoading) return;
if (!user) {
if (isUserLoading || !user) {
return {
kind: "user" as const,
key: "anonymous",
@@ -41,12 +38,10 @@ export function LaunchDarklyProvider({ children }: { children: ReactNode }) {
return <>{children}</>;
}
if (isUserLoading) {
return <LoadingSpinner size="large" cover />;
}
return (
<LDProvider
// Add this key prop. It will be 'anonymous' when logged out,
key={context.key}
clientSideID={clientId ?? ""}
context={context}
timeout={LAUNCHDARKLY_INIT_TIMEOUT_MS}

View File

@@ -3,45 +3,45 @@ const MANIFEST = 'flutter-app-manifest';
const TEMP = 'flutter-temp-cache';
const CACHE_NAME = 'flutter-app-cache';
const RESOURCES = {"canvaskit/skwasm.worker.js": "51253d3321b11ddb8d73fa8aa87d3b15",
"canvaskit/skwasm.js": "95f16c6690f955a45b2317496983dbe9",
"canvaskit/canvaskit.wasm": "d9f69e0f428f695dc3d66b3a83a4aa8e",
"canvaskit/skwasm.wasm": "d1fde2560be92c0b07ad9cf9acb10d05",
"canvaskit/canvaskit.js": "5caccb235fad20e9b72ea6da5a0094e6",
"canvaskit/chromium/canvaskit.wasm": "393ec8fb05d94036734f8104fa550a67",
"canvaskit/chromium/canvaskit.js": "ffb2bb6484d5689d91f393b60664d530",
"icons/Icon-maskable-192.png": "c457ef57daa1d16f64b27b786ec2ea3c",
"icons/Icon-maskable-512.png": "301a7604d45b3e739efc881eb04896ea",
const RESOURCES = {"flutter.js": "6fef97aeca90b426343ba6c5c9dc5d4a",
"icons/Icon-512.png": "96e752610906ba2a93c65f8abe1645f1",
"icons/Icon-maskable-512.png": "301a7604d45b3e739efc881eb04896ea",
"icons/Icon-192.png": "ac9a721a12bbc803b44f645561ecb1e1",
"icons/Icon-maskable-192.png": "c457ef57daa1d16f64b27b786ec2ea3c",
"manifest.json": "0fa552613b8ec0fda5cda565914e3b16",
"favicon.png": "5dcef449791fa27946b3d35ad8803796",
"version.json": "46a52461e018faa623d9196334aa3f50",
"index.html": "e6981504a32bf86f892909c1875df208",
"/": "e6981504a32bf86f892909c1875df208",
"main.dart.js": "6fcbf8bbcb0a76fae9029f72ac7fbdc3",
"assets/AssetManifest.json": "1b1e4a4276722b65eb1ef765e2991840",
"assets/packages/cupertino_icons/assets/CupertinoIcons.ttf": "055d9e87e4a40dbf72b2af1a20865d57",
"assets/packages/fluttertoast/assets/toastify.js": "56e2c9cedd97f10e7e5f1cebd85d53e3",
"assets/packages/fluttertoast/assets/toastify.css": "a85675050054f179444bc5ad70ffc635",
"index.html": "9372da2f60aeda515b1c03976b95fbeb",
"/": "9372da2f60aeda515b1c03976b95fbeb",
"assets/shaders/ink_sparkle.frag": "f8b80e740d33eb157090be4e995febdf",
"assets/fonts/MaterialIcons-Regular.otf": "245e0462249d95ad589a087f1c9f58e1",
"assets/assets/images/twitter_logo.png": "af6c11b96a5e732b8dfda86a2351ecab",
"assets/assets/images/discord_logo.png": "0e4a4162c5de8665a7d63ae9665405ae",
"assets/assets/images/google_logo.svg.png": "0e29f8e1acfb8996437dbb2b0f591f19",
"assets/assets/images/autogpt_logo.png": "6a5362a7d1f2f840e43ee259e733476c",
"assets/assets/images/github_logo.svg.png": "ba087b073efdc4996b035d3a12bad0e4",
"assets/assets/scrape_synthesize_tree_structure.json": "a9665c1b465bb0cb939c7210f2bf0b13",
"assets/assets/tree_structure.json": "cda9b1a239f956c547411efad9f7c794",
"assets/assets/coding_tree_structure.json": "017a857cf3e274346a0a7eab4ce02eed",
"assets/assets/general_tree_structure.json": "41dfbcdc2349dcdda2b082e597c6d5ee",
"assets/assets/google_logo.svg.png": "0e29f8e1acfb8996437dbb2b0f591f19",
"assets/assets/tree_structure.json": "cda9b1a239f956c547411efad9f7c794",
"assets/assets/data_tree_structure.json": "5f9627548304155821968182f3883ca7",
"assets/assets/github_logo.svg.png": "ba087b073efdc4996b035d3a12bad0e4",
"assets/assets/images/discord_logo.png": "0e4a4162c5de8665a7d63ae9665405ae",
"assets/assets/images/github_logo.svg.png": "ba087b073efdc4996b035d3a12bad0e4",
"assets/assets/images/twitter_logo.png": "af6c11b96a5e732b8dfda86a2351ecab",
"assets/assets/images/google_logo.svg.png": "0e29f8e1acfb8996437dbb2b0f591f19",
"assets/assets/images/autogpt_logo.png": "6a5362a7d1f2f840e43ee259e733476c",
"assets/assets/google_logo.svg.png": "0e29f8e1acfb8996437dbb2b0f591f19",
"assets/assets/scrape_synthesize_tree_structure.json": "a9665c1b465bb0cb939c7210f2bf0b13",
"assets/assets/data_tree_structure.json": "5f9627548304155821968182f3883ca7",
"assets/fonts/MaterialIcons-Regular.otf": "245e0462249d95ad589a087f1c9f58e1",
"assets/NOTICES": "28ba0c63fc6e4d1ef829af7441e27f78",
"assets/AssetManifest.bin": "791447d17744ac2ade3999c1672fdbe8",
"assets/packages/fluttertoast/assets/toastify.css": "a85675050054f179444bc5ad70ffc635",
"assets/packages/fluttertoast/assets/toastify.js": "56e2c9cedd97f10e7e5f1cebd85d53e3",
"assets/packages/cupertino_icons/assets/CupertinoIcons.ttf": "055d9e87e4a40dbf72b2af1a20865d57",
"assets/FontManifest.json": "dc3d03800ccca4601324923c0b1d6d57",
"flutter.js": "6fef97aeca90b426343ba6c5c9dc5d4a"};
"assets/AssetManifest.bin": "791447d17744ac2ade3999c1672fdbe8",
"assets/AssetManifest.json": "1b1e4a4276722b65eb1ef765e2991840",
"canvaskit/chromium/canvaskit.wasm": "393ec8fb05d94036734f8104fa550a67",
"canvaskit/chromium/canvaskit.js": "ffb2bb6484d5689d91f393b60664d530",
"canvaskit/skwasm.worker.js": "51253d3321b11ddb8d73fa8aa87d3b15",
"canvaskit/skwasm.js": "95f16c6690f955a45b2317496983dbe9",
"canvaskit/canvaskit.wasm": "d9f69e0f428f695dc3d66b3a83a4aa8e",
"canvaskit/canvaskit.js": "5caccb235fad20e9b72ea6da5a0094e6",
"canvaskit/skwasm.wasm": "d1fde2560be92c0b07ad9cf9acb10d05",
"favicon.png": "5dcef449791fa27946b3d35ad8803796",
"version.json": "46a52461e018faa623d9196334aa3f50",
"main.dart.js": "6fcbf8bbcb0a76fae9029f72ac7fbdc3"};
// The application shell files that are downloaded before a service worker can
// start.
const CORE = ["main.dart.js",

View File

@@ -35,7 +35,7 @@
<script>
// The value below is injected by flutter build, do not touch.
const serviceWorkerVersion = "726743092";
const serviceWorkerVersion = "1374873384";
</script>
<!-- This script adds the flutter initialization JS code -->
<script src="flutter.js" defer></script>