Skip to content
4 changes: 4 additions & 0 deletions service/app/agents/builtin/react.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
NodeType,
ToolNodeConfig,
)
from app.schemas.prompt_config import PromptConfig

# ReAct Agent configuration using direct LLM and TOOL nodes (NOT subgraph)
# This ensures proper streaming support through LangGraph's messages mode
Expand Down Expand Up @@ -62,6 +63,9 @@
GraphEdgeConfig(from_node="tools", to_node="agent"),
],
entry_point="agent",
prompt_config=PromptConfig(
custom_instructions="", # User should set their instructions here
),
metadata={
"builtin_key": "react",
"display_name": "ReAct Agent",
Expand Down
77 changes: 35 additions & 42 deletions service/app/agents/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,13 @@
3. Return CompiledStateGraph + AgentEventContext

Config Resolution Order:
1. agent_config.graph_config exists → use it directly
2. agent_config.graph_config.metadata.builtin_key exists → use builtin config
3. agent_config.graph_config.metadata.system_agent_key exists → use builtin config (legacy)
4. No config → fall back to "react" builtin
1. agent_config.graph_config exists → use it as the source of truth
(builtin_key in metadata is ONLY used for analytics/UI, not to replace the config)
2. No config → fall back to "react" builtin

IMPORTANT: The graph_config is always the single source of truth. This ensures
forked agents retain their customizations (custom prompts, tools, etc.) rather
than being replaced with the generic builtin config.

The default agent is the "react" builtin agent.
"""
Expand Down Expand Up @@ -154,44 +157,37 @@ def _resolve_agent_config(
Resolve which GraphConfig to use for an agent.

Resolution order:
1. agent_config has graph_config → use it, check for builtin_key/system_agent_key
1. agent_config has graph_config → use it (graph_config is the source of truth)
2. agent_config is None or has no graph_config → use default builtin (react)

The builtin_key in metadata is ONLY used for analytics/UI purposes, NOT to replace
the agent's actual graph_config. This ensures forked agents retain their customizations.

Args:
agent_config: Agent configuration from database (may be None)
system_prompt: System prompt to inject if using react agent
system_prompt: System prompt to inject into the config

Returns:
Tuple of (raw_config_dict, agent_type_key)
- raw_config_dict: GraphConfig as dict (for version detection)
- agent_type_key: Agent type for events (e.g., "react", "deep_research")
- agent_type_key: Agent type for events (e.g., "react", "deep_research", "graph")
"""
from app.agents.builtin import get_builtin_config

if agent_config and agent_config.graph_config:
# Agent has a graph_config
# Agent has a graph_config - use it as the source of truth
raw_config = agent_config.graph_config
metadata = raw_config.get("metadata", {})

# Check for builtin_key or system_agent_key (legacy)
builtin_key = metadata.get("builtin_key") or metadata.get("system_agent_key")

if builtin_key:
# This config references a builtin - use the builtin config
builtin_config = get_builtin_config(builtin_key)
if builtin_config:
# Apply system_prompt override for react
config_dict = builtin_config.model_dump()
if builtin_key == "react" and system_prompt:
config_dict = _inject_system_prompt(config_dict, system_prompt)
return config_dict, builtin_key
# Extract agent type key for analytics/UI, but DON'T replace the config
# This fixes the bug where forked agents lost their customizations
agent_type_key = metadata.get("builtin_key") or metadata.get("system_agent_key") or "graph"

# Builtin not found, use the provided config as-is
logger.warning(f"Builtin '{builtin_key}' not found, using provided config")
return raw_config, builtin_key or "graph"
# Inject system_prompt into the agent's actual config (not a builtin replacement)
if system_prompt:
raw_config = _inject_system_prompt(raw_config, system_prompt)

# Pure user-defined graph config
return raw_config, "graph"
return raw_config, agent_type_key

# No agent config or no graph_config - use default builtin (react)
builtin_config = get_builtin_config(DEFAULT_BUILTIN_AGENT)
Expand All @@ -209,9 +205,12 @@ def _inject_system_prompt(config_dict: dict[str, Any], system_prompt: str) -> di
"""
Inject system_prompt into a graph config.

Handles both:
1. Component nodes with stdlib:react - updates config_overrides
2. LLM nodes - updates prompt_template
Handles ALL nodes that support system_prompt:
1. Component nodes - updates config_overrides.system_prompt
2. LLM nodes - updates llm_config.prompt_template

This injects into ALL matching nodes (not just the first), ensuring
forked agents with multiple components all receive the custom prompt.

Args:
config_dict: GraphConfig as dict
Expand All @@ -220,29 +219,23 @@ def _inject_system_prompt(config_dict: dict[str, Any], system_prompt: str) -> di
Returns:
Modified config dict with system_prompt injected
"""
# Deep copy to avoid mutating original
import copy

config = copy.deepcopy(config_dict)

# Find nodes and inject system_prompt (first matching node only)
for node in config.get("nodes", []):
# Handle component nodes (existing behavior)
# Inject into ALL component nodes that support system_prompt
if node.get("type") == "component":
comp_config = node.get("component_config", {})
comp_ref = comp_config.get("component_ref", {})

# Only inject into react components
if comp_ref.get("key") == "react":
overrides = comp_config.setdefault("config_overrides", {})
overrides["system_prompt"] = system_prompt
break
comp_config = node.setdefault("component_config", {})
overrides = comp_config.setdefault("config_overrides", {})
overrides["system_prompt"] = system_prompt
# Continue - don't break (inject into all components)

# Handle LLM nodes
# Inject into ALL LLM nodes
elif node.get("type") == "llm":
llm_config = node.get("llm_config", {})
llm_config = node.setdefault("llm_config", {})
llm_config["prompt_template"] = system_prompt
break
# Continue - don't break (inject into all LLM nodes)

return config

Expand Down
24 changes: 23 additions & 1 deletion service/app/api/v1/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from app.core.system_agent import SystemAgentManager
from app.infra.database import get_session
from app.middleware.auth import get_current_user
from app.models.agent import AgentCreate, AgentRead, AgentReadWithDetails, AgentScope, AgentUpdate
from app.models.agent import AgentCreate, AgentRead, AgentReadWithDetails, AgentScope, AgentUpdate, ConfigVisibility
from app.models.session_stats import AgentStatsAggregated, DailyStatsResponse, YesterdaySummary
from app.repos import AgentRepository, KnowledgeSetRepository, ProviderRepository
from app.repos.agent_marketplace import AgentMarketplaceRepository
Expand Down Expand Up @@ -198,6 +198,13 @@ async def create_agent_from_template(
# Export config for forking
graph_config_dict = builtin_config.model_dump()

# Simplify prompt_config to only show custom_instructions
# (hide verbose PromptConfig defaults from user)
if graph_config_dict.get("prompt_config"):
graph_config_dict["prompt_config"] = {
"custom_instructions": graph_config_dict["prompt_config"].get("custom_instructions", "")
}

# Add builtin_key to metadata so the agent can reference the builtin at runtime
if "metadata" not in graph_config_dict:
graph_config_dict["metadata"] = {}
Expand Down Expand Up @@ -431,9 +438,17 @@ async def get_agent(
agent_repo = AgentRepository(db)
mcp_servers = await agent_repo.get_agent_mcp_servers(agent.id)

# Check if user is the owner
is_owner = agent.user_id == user_id

# Create agent dict with MCP servers
agent_dict = agent.model_dump()
agent_dict["mcp_servers"] = mcp_servers

# Hide config for non-owners when visibility is hidden
if not is_owner and agent.config_visibility == ConfigVisibility.HIDDEN:
agent_dict["graph_config"] = None

return AgentReadWithDetails(**agent_dict)
except ErrCodeError as e:
raise handle_auth_error(e)
Expand Down Expand Up @@ -472,6 +487,13 @@ async def update_agent(
if agent.scope == AgentScope.SYSTEM:
raise HTTPException(status_code=403, detail="Cannot modify system agents")

# Block config editing for non-editable agents
if not agent.config_editable and agent_data.graph_config is not None:
raise HTTPException(
status_code=403,
detail="This agent's configuration cannot be edited",
)

if agent_data.provider_id is not None:
provider_repo = ProviderRepository(db)
provider = await provider_repo.get_provider_by_id(agent_data.provider_id)
Expand Down
6 changes: 5 additions & 1 deletion service/app/api/v1/marketplace.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
from app.core.marketplace import AgentMarketplaceService
from app.infra.database import get_session
from app.middleware.auth import get_current_user
from app.models.agent import AgentRead
from app.models.agent import AgentRead, ForkMode
from app.models.agent_marketplace import (
AgentMarketplaceRead,
AgentMarketplaceReadWithSnapshot,
Expand All @@ -42,6 +42,7 @@ class PublishRequest(BaseModel):
commit_message: str
is_published: bool = True
readme: str | None = None
fork_mode: ForkMode = ForkMode.EDITABLE


class PublishResponse(BaseModel):
Expand All @@ -59,6 +60,7 @@ class UpdateListingRequest(BaseModel):

readme: str | None = None
is_published: bool | None = None
fork_mode: ForkMode | None = None


class PublishVersionRequest(BaseModel):
Expand Down Expand Up @@ -151,6 +153,7 @@ async def publish_agent(
commit_message=request.commit_message,
is_published=request.is_published,
readme=request.readme,
fork_mode=request.fork_mode,
)
if not listing:
raise HTTPException(status_code=404, detail="Marketplace listing not found")
Expand Down Expand Up @@ -208,6 +211,7 @@ async def update_listing(
update_data = AgentMarketplaceUpdate(
readme=request.readme,
is_published=request.is_published,
fork_mode=request.fork_mode,
)
updated_listing = await marketplace_service.update_listing_details(marketplace_id, update_data)

Expand Down
36 changes: 32 additions & 4 deletions service/app/core/marketplace/agent_marketplace_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from sqlmodel.ext.asyncio.session import AsyncSession

from app.core.storage import FileScope
from app.models.agent import Agent, AgentCreate, AgentScope
from app.models.agent import Agent, AgentCreate, AgentScope, ConfigVisibility, ForkMode
from app.models.agent_marketplace import AgentMarketplace, AgentMarketplaceCreate, AgentMarketplaceUpdate
from app.models.agent_snapshot import AgentSnapshot, AgentSnapshotCreate
from app.models.file import FileCreate
Expand Down Expand Up @@ -102,7 +102,12 @@ async def create_snapshot_from_agent(self, agent: Agent, commit_message: str) ->
return snapshot

async def publish_agent(
self, agent: Agent, commit_message: str, is_published: bool = True, readme: str | None = None
self,
agent: Agent,
commit_message: str,
is_published: bool = True,
readme: str | None = None,
fork_mode: ForkMode = ForkMode.EDITABLE,
) -> AgentMarketplace | None:
"""
Publishes an agent to the marketplace or updates an existing listing.
Expand All @@ -112,6 +117,7 @@ async def publish_agent(
commit_message: Description of changes.
is_published: Whether to set the listing as published.
readme: Optional markdown README content.
fork_mode: Access mode for forked agents.

Returns:
The marketplace listing.
Expand All @@ -134,6 +140,7 @@ async def publish_agent(
tags=agent.tags or [],
is_published=is_published,
readme=readme,
fork_mode=fork_mode,
)
listing = await self.marketplace_repo.update_listing(existing_listing.id, update_data)

Expand All @@ -154,6 +161,7 @@ async def publish_agent(
avatar=agent.avatar,
tags=agent.tags or [],
readme=readme,
fork_mode=fork_mode,
)
listing = await self.marketplace_repo.create_listing(listing_data)

Expand Down Expand Up @@ -245,12 +253,25 @@ async def fork_agent(self, marketplace_id: UUID, user_id: str, fork_name: str |
final_name = f"{base_name} ({counter})"
counter += 1

# Filter out default_* tags - those are for system agents only
# Forked agents should not be treated as system defaults
original_tags = config.get("tags", [])
forked_tags = [t for t in original_tags if not t.startswith("default_")]

# Determine config access based on listing's fork_mode
if listing.fork_mode == ForkMode.EDITABLE:
config_visibility = ConfigVisibility.VISIBLE
config_editable = True
else: # LOCKED
config_visibility = ConfigVisibility.HIDDEN
config_editable = False

agent_create = AgentCreate(
scope=AgentScope.USER,
name=final_name,
description=config.get("description"),
avatar=config.get("avatar"),
tags=config.get("tags", []),
tags=forked_tags,
model=config.get("model"),
temperature=config.get("temperature"),
prompt=config.get("prompt"), # Legacy field for backward compat
Expand All @@ -259,6 +280,8 @@ async def fork_agent(self, marketplace_id: UUID, user_id: str, fork_name: str |
knowledge_set_id=None, # Create empty knowledge set
mcp_server_ids=[], # Will link compatible MCPs below
graph_config=config.get("graph_config"), # Restore from snapshot
config_visibility=config_visibility,
config_editable=config_editable,
)

# Create the forked agent
Expand Down Expand Up @@ -630,12 +653,17 @@ async def pull_listing_update(self, agent_id: UUID, user_id: str) -> Agent:
# We preserve the user's provider_id and knowledge_set_id if possible
# but update core logic fields

# Filter out default_* tags - those are for system agents only
# Forked agents should not become system defaults when pulling updates
original_tags = config.get("tags", [])
filtered_tags = [t for t in original_tags if not t.startswith("default_")]

from app.models.agent import AgentUpdate

update_data = AgentUpdate(
description=config.get("description"),
avatar=config.get("avatar"),
tags=config.get("tags", []),
tags=filtered_tags,
model=config.get("model"),
temperature=config.get("temperature"),
prompt=config.get("prompt"), # Legacy field for backward compat
Expand Down
Loading