feat(platform): Forking agent in Library (#9870)

This PR introduces copying agents feature in the Library. Users can copy
and download their library agents but they can edit only the ones they
own (included copied ones).

### Changes 🏗️

- DB migration: add relation in `AgentGraph`: `forked_from_id` and
`forked_from_version`
- Add `fork_graph` function that makes a hardcopy of agent graph and its
nodes (all with new ids)
- Add `fork_library_agent` that copies library agent and its graph for a
user
- Add endpoint `/library/agents/{libraryAgentId}/fork`
- Add UI to `library/agents/[id]/page.tsx`: `Edit a copy` button with
dialog confirmation

### 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] Agent can be copied, edited and runs
This commit is contained in:
Krzysztof Czerwinski
2025-04-23 18:28:42 +02:00
committed by GitHub
parent 5047e99fd1
commit 160a622ba4
8 changed files with 173 additions and 4 deletions

View File

@@ -172,6 +172,8 @@ class BaseGraph(BaseDbModel):
description: str
nodes: list[Node] = []
links: list[Link] = []
forked_from_id: str | None = None
forked_from_version: int | None = None
@computed_field
@property
@@ -570,6 +572,8 @@ class GraphModel(Graph):
id=graph.id,
user_id=graph.userId if not for_export else "",
version=graph.version,
forked_from_id=graph.forkedFromId,
forked_from_version=graph.forkedFromVersion,
is_active=graph.isActive,
name=graph.name or "",
description=graph.description or "",
@@ -847,6 +851,27 @@ async def create_graph(graph: Graph, user_id: str) -> GraphModel:
raise ValueError(f"Created graph {graph.id} v{graph.version} is not in DB")
async def fork_graph(graph_id: str, graph_version: int, user_id: str) -> GraphModel:
"""
Forks a graph by copying it and all its nodes and links to a new graph.
"""
async with transaction() as tx:
graph = await get_graph(graph_id, graph_version, user_id=user_id)
if not graph:
raise ValueError(f"Graph {graph_id} v{graph_version} not found")
# Set forked from ID and version as itself as it's about ot be copied
graph.forked_from_id = graph.id
graph.forked_from_version = graph.version
graph.name = f"{graph.name} (copy)"
graph.reassign_ids(user_id=user_id, reassign_graph_id=True)
graph.validate_graph(for_run=False)
await __create_graph(tx, graph, user_id)
return graph
async def __create_graph(tx, graph: Graph, user_id: str):
graphs = [graph] + graph.sub_graphs
@@ -859,6 +884,8 @@ async def __create_graph(tx, graph: Graph, user_id: str):
description=graph.description,
isActive=graph.is_active,
userId=user_id,
forkedFromId=graph.forked_from_id,
forkedFromVersion=graph.forked_from_version,
)
for graph in graphs
]

View File

@@ -13,12 +13,17 @@ import backend.server.v2.library.model as library_model
import backend.server.v2.store.exceptions as store_exceptions
import backend.server.v2.store.image_gen as store_image_gen
import backend.server.v2.store.media as store_media
from backend.data import db
from backend.data import graph as graph_db
from backend.data.db import locked_transaction
from backend.data.includes import library_agent_include
from backend.integrations.creds_manager import IntegrationCredentialsManager
from backend.integrations.webhooks.graph_lifecycle_hooks import on_graph_activate
from backend.util.settings import Config
logger = logging.getLogger(__name__)
config = Config()
integration_creds_manager = IntegrationCredentialsManager()
async def list_library_agents(
@@ -206,7 +211,7 @@ async def add_generated_agent_image(
async def create_library_agent(
graph: backend.data.graph.GraphModel,
user_id: str,
) -> prisma.models.LibraryAgent:
) -> library_model.LibraryAgent:
"""
Adds an agent to the user's library (LibraryAgent table).
@@ -227,7 +232,7 @@ async def create_library_agent(
)
try:
return await prisma.models.LibraryAgent.prisma().create(
agent = await prisma.models.LibraryAgent.prisma().create(
data=prisma.types.LibraryAgentCreateInput(
isCreatedByUser=(user_id == graph.user_id),
useGraphIsActiveVersion=True,
@@ -238,8 +243,10 @@ async def create_library_agent(
"graphVersionId": {"id": graph.id, "version": graph.version}
}
},
)
),
include={"AgentGraph": True},
)
return library_model.LibraryAgent.from_db(agent)
except prisma.errors.PrismaError as e:
logger.error(f"Database error creating agent in library: {e}")
raise store_exceptions.DatabaseError("Failed to create agent in library") from e
@@ -662,3 +669,47 @@ async def delete_preset(user_id: str, preset_id: str) -> None:
except prisma.errors.PrismaError as e:
logger.error(f"Database error deleting preset: {e}")
raise store_exceptions.DatabaseError("Failed to delete preset") from e
async def fork_library_agent(library_agent_id: str, user_id: str):
"""
Clones a library agent and its underyling graph and nodes (with new ids) for the given user.
Args:
library_agent_id: The ID of the library agent to fork.
user_id: The ID of the user who owns the library agent.
Returns:
The forked LibraryAgent.
Raises:
DatabaseError: If there's an error during the forking process.
"""
logger.debug(f"Forking library agent {library_agent_id} for user {user_id}")
try:
async with db.locked_transaction(f"usr_trx_{user_id}-fork_agent"):
# Fetch the original agent
original_agent = await get_library_agent(library_agent_id, user_id)
# Check if user owns the library agent
# TODO: once we have open/closed sourced agents this needs to be enabled ~kcze
# + update library/agents/[id]/page.tsx agent actions
# if not original_agent.can_access_graph:
# raise store_exceptions.DatabaseError(
# f"User {user_id} cannot access library agent graph {library_agent_id}"
# )
# Fork the underlying graph and nodes
new_graph = await graph_db.fork_graph(
original_agent.graph_id, original_agent.graph_version, user_id
)
new_graph = await on_graph_activate(
new_graph,
get_credentials=lambda id: integration_creds_manager.get(user_id, id),
)
# Create a library agent for the new graph
return await create_library_agent(new_graph, user_id)
except prisma.errors.PrismaError as e:
logger.error(f"Database error cloning library agent: {e}")
raise store_exceptions.DatabaseError("Failed to fork library agent") from e

View File

@@ -190,3 +190,14 @@ async def update_library_agent(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to update library agent",
) from e
@router.post("/{library_agent_id}/fork")
async def fork_library_agent(
library_agent_id: str,
user_id: str = Depends(autogpt_auth_lib.depends.get_user_id),
) -> library_model.LibraryAgent:
return await library_db.fork_library_agent(
library_agent_id=library_agent_id,
user_id=user_id,
)

View File

@@ -0,0 +1,7 @@
-- AlterTable
ALTER TABLE "AgentGraph"
ADD COLUMN "forkedFromId" TEXT,
ADD COLUMN "forkedFromVersion" INTEGER;
-- AddForeignKey
ALTER TABLE "AgentGraph" ADD CONSTRAINT "AgentGraph_forkedFromId_forkedFromVersion_fkey" FOREIGN KEY ("forkedFromId", "forkedFromVersion") REFERENCES "AgentGraph"("id", "version") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -118,6 +118,11 @@ model AgentGraph {
// This allows us to delete user data with deleting the agent which maybe in use by other users
User User @relation(fields: [userId], references: [id], onDelete: Cascade)
forkedFromId String?
forkedFromVersion Int?
forkedFrom AgentGraph? @relation("AgentGraphForks", fields: [forkedFromId, forkedFromVersion], references: [id, version])
forks AgentGraph[] @relation("AgentGraphForks")
Nodes AgentNode[]
Executions AgentGraphExecution[]

View File

@@ -23,6 +23,16 @@ import AgentRunDetailsView from "@/components/agents/agent-run-details-view";
import AgentRunsSelectorList from "@/components/agents/agent-runs-selector-list";
import AgentScheduleDetailsView from "@/components/agents/agent-schedule-details-view";
import { useOnboarding } from "@/components/onboarding/onboarding-provider";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { useToast } from "@/components/ui/use-toast";
export default function AgentRunsPage(): React.ReactElement {
const { id: agentID }: { id: LibraryAgentID } = useParams();
@@ -51,6 +61,8 @@ export default function AgentRunsPage(): React.ReactElement {
const [confirmingDeleteAgentRun, setConfirmingDeleteAgentRun] =
useState<GraphExecutionMeta | null>(null);
const { state, updateState } = useOnboarding();
const [copyAgentDialogOpen, setCopyAgentDialogOpen] = useState(false);
const { toast } = useToast();
const openRunDraftView = useCallback(() => {
selectView({ type: "run" });
@@ -237,6 +249,23 @@ export default function AgentRunsPage(): React.ReactElement {
[api, agent],
);
const copyAgent = useCallback(async () => {
setCopyAgentDialogOpen(false);
api
.forkLibraryAgent(agentID)
.then((newAgent) => {
router.push(`/library/agents/${newAgent.id}`);
})
.catch((error) => {
console.error("Error copying agent:", error);
toast({
title: "Error copying agent",
description: `An error occurred while copying the agent: ${error.message}`,
variant: "destructive",
});
});
}, [agentID, api, router, toast]);
const agentActions: ButtonAction[] = useMemo(
() => [
...(agent?.can_access_graph
@@ -245,9 +274,13 @@ export default function AgentRunsPage(): React.ReactElement {
label: "Open graph in builder",
href: `/build?flowID=${agent.graph_id}&flowVersion=${agent.graph_version}`,
},
{ label: "Export agent to file", callback: downloadGraph },
]
: []),
{ label: "Export agent to file", callback: downloadGraph },
{
label: "Edit a copy",
callback: () => setCopyAgentDialogOpen(true),
},
{
label: "Delete agent",
variant: "destructive",
@@ -339,6 +372,35 @@ export default function AgentRunsPage(): React.ReactElement {
confirmingDeleteAgentRun && deleteRun(confirmingDeleteAgentRun)
}
/>
{/* Copy agent confirmation dialog */}
<Dialog
onOpenChange={setCopyAgentDialogOpen}
open={copyAgentDialogOpen}
>
<DialogContent>
<DialogHeader>
<DialogTitle>You&apos;re making an editable copy</DialogTitle>
<DialogDescription className="pt-2">
We&apos;ll save a new version of this agent to your library so
you can customize it however you&apos;d like. You&apos;ll still
have the original from the marketplace too it won&apos;t be
changed and can&apos;t be edited.
</DialogDescription>
</DialogHeader>
<DialogFooter className="justify-end">
<Button
type="button"
variant="outline"
onClick={() => setCopyAgentDialogOpen(false)}
>
Cancel
</Button>
<Button type="button" onClick={copyAgent}>
Continue
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
);

View File

@@ -600,6 +600,10 @@ export default class BackendAPI {
await this._request("PUT", `/library/agents/${libraryAgentId}`, params);
}
forkLibraryAgent(libraryAgentId: LibraryAgentID): Promise<LibraryAgent> {
return this._request("POST", `/library/agents/${libraryAgentId}/fork`);
}
listLibraryAgentPresets(params?: {
page?: number;
page_size?: number;

View File

@@ -280,6 +280,8 @@ export type GraphMeta = {
is_active: boolean;
name: string;
description: string;
forked_from_id?: GraphID | null;
forked_from_version?: number | null;
input_schema: GraphIOSchema;
output_schema: GraphIOSchema;
credentials_input_schema: {