Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@
HireResult,
)
from backend.api.features.library import db as library_db
from backend.data.db import prisma as db_client
from backend.data.expert_attribution import (
resolve_attributable_expert as resolve_attributable_expert_row,
)
from backend.data.expert_spend import get_weekly_spend
from backend.data.user import get_user_by_id
from backend.util.timezone_utils import get_user_timezone_or_utc
Expand Down Expand Up @@ -496,6 +500,21 @@ async def resolve_expert_for_graph(user_id: str, graph_id: str) -> str | None:
return expert_ids.pop()


async def resolve_attributable_expert(
user_id: str, expert_id: str | None
) -> str | None:
"""Read-only expert-attribution lookup.

Durable writes use the same shared guard with a row lock inside their own
transaction; this lookup is for discovery and compatibility only.
"""
return await resolve_attributable_expert_row(
db_client,
user_id,
expert_id,
)


async def archive_expert(user_id: str, expert_id: str) -> None:
updated = await prisma.models.Expert.prisma().update_many(
where={"id": expert_id, "ownerUserId": user_id, "isTemplate": False},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
import backend.api.features.store.model as store_model
from backend.api.features.experts import experts_db, scheduling, seed
from backend.api.features.experts.models import ExpertSoulFieldsPatch, ExpertSoulUpdate
from backend.api.features.library import db as library_db
from backend.api.features.library import model as library_model
from backend.api.model import CreateGraph
from backend.blocks.io import AgentInputBlock
from backend.copilot.model import create_chat_session
from backend.data.graph import Graph, Node
from backend.data.user import get_or_create_user
from backend.usecases.sample import create_test_user
Expand Down Expand Up @@ -409,6 +412,57 @@ async def test_hire_schedule_failure_marks_needs_setup(
assert result.failed_preloads == []


@pytest.mark.asyncio(loop_scope="session")
async def test_attributed_writes_fall_back_when_archived_after_validation(
server: SpinTestServer,
):
"""An archive between an earlier lookup and either durable write wins.

ChatSession and AgentPreset must both persist without the stale expert id;
otherwise their work would be permanently rejected by the archived-expert
run-budget gate.
"""
slv_id = await _seed_store_listing(server)
owner = await _create_seed_user()
template = await _seed_template(name="Maria", preload_listings=[slv_id])
hired = await experts_db.hire_expert(owner.id, template.id, None)
expert_id = hired.expert.id
workflow = hired.expert.workflows[0]
assert workflow.library_agent_id is not None
library_agent = await prisma.models.LibraryAgent.prisma().find_unique(
where={"id": workflow.library_agent_id}
)
assert library_agent is not None

assert (
await experts_db.resolve_attributable_expert(owner.id, expert_id) == expert_id
)
await prisma.models.Expert.prisma().update(
where={"id": expert_id}, data={"isArchived": True}
)

session = await create_chat_session(
owner.id,
dry_run=False,
expert_id=expert_id,
)
assert session.expert_id is None

preset = await library_db.create_preset(
owner.id,
library_model.LibraryAgentPresetCreatable(
graph_id=library_agent.agentGraphId,
graph_version=library_agent.agentGraphVersion,
inputs={},
credentials={},
name="Atomic attribution fallback",
description="",
),
expert_id=expert_id,
)
assert preset.expert_id is None


@pytest.mark.asyncio(loop_scope="session")
async def test_archive_pauses_detaches_and_revive_reattaches(
server: SpinTestServer, test_user
Expand Down
41 changes: 32 additions & 9 deletions autogpt_platform/backend/backend/api/features/library/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
)
from backend.data.db import transaction
from backend.data.execution import get_graph_execution
from backend.data.expert_attribution import resolve_attributable_expert
from backend.data.graph import GraphSettings
from backend.data.includes import (
AGENT_PRESET_INCLUDE,
Expand Down Expand Up @@ -1983,9 +1984,9 @@ async def create_preset(
webhook_id: Internal-only; not part of the public request model. Only
trusted callers (the setup-trigger flow, legacy migration) pass a
webhook they provisioned for the caller.
expert_id: Expert attribution, resolved by the route layer (this
module cannot import experts_db without a cycle). Runs fired by
the preset inherit it.
expert_id: Requested expert attribution. Active ownership is validated
atomically with preset persistence. Runs fired by the preset
inherit the validated value.

Returns:
The newly created LibraryAgentPreset.
Expand Down Expand Up @@ -2039,12 +2040,34 @@ async def create_preset(
create_input["organizationId"] = graph.organization_id
if graph.team_id:
create_input["teamId"] = graph.team_id
if expert_id:
create_input["expertId"] = expert_id
new_preset = await prisma.models.AgentPreset.prisma().create(
data=create_input,
include=AGENT_PRESET_INCLUDE,
)
requested_expert_id = expert_id
if requested_expert_id:
async with transaction() as tx:
expert_id = await resolve_attributable_expert(
tx,
user_id,
requested_expert_id,
lock_for_update=True,
)
if expert_id:
create_input["expertId"] = expert_id
new_preset = await prisma.models.AgentPreset.prisma(tx).create(
data=create_input,
include=AGENT_PRESET_INCLUDE,
)
if expert_id is None:
logger.warning(
"Ignoring inactive/unowned expert %s while creating preset "
"%s for user %s",
requested_expert_id,
preset.name,
user_id,
)
else:
new_preset = await prisma.models.AgentPreset.prisma().create(
data=create_input,
include=AGENT_PRESET_INCLUDE,
)
return library_model.LibraryAgentPreset.from_db(new_preset)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ async def setup_triggered_preset(
description: str,
trigger_config: dict[str, Any],
agent_credentials: dict[str, CredentialsMetaInput],
expert_id: str | None = None,
) -> models.LibraryAgentPreset:
"""Create a webhook-triggered ``LibraryAgentPreset`` for the given graph.

Expand All @@ -61,6 +62,14 @@ async def setup_triggered_preset(
f"Graph #{graph_id} does not have a webhook trigger node"
)

# A supplied session expert wins and deliberately suppresses graph-match
# re-attribution. create_preset validates it under the same transaction as
# the durable write, falling back to an unattributed preset if archival
# wins the race. With no supplied expert, preserve the existing unique
# graph-match behaviour; create_preset atomically re-validates that result.
if expert_id is None:
expert_id = await experts_db.resolve_expert_for_graph(user_id, graph.id)

trigger_config_with_credentials = {
**trigger_config,
**(
Expand Down Expand Up @@ -95,9 +104,7 @@ async def setup_triggered_preset(
is_active=True,
),
webhook_id=new_webhook.id,
# A trigger on an expert-installed workflow fires as the expert's
# work: unique (user, graph) → expert matches keep the attribution.
expert_id=await experts_db.resolve_expert_for_graph(user_id, graph.id),
expert_id=expert_id,
)


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ def _graph():
return graph


def _patches(*, graph, webhook=..., feedback=None, preset=None):
def _patches(*, graph, webhook=..., feedback=None, preset=None, graph_expert=None):
"""Patch triggers.py's collaborators. ``webhook=...`` defaults to a stub
webhook; pass ``webhook=None`` + ``feedback`` to exercise the rejection."""
webhook; pass ``webhook=None`` + ``feedback`` to exercise the rejection.
``graph_expert`` is the expert the graph-match fallback resolves to."""
new_webhook = MagicMock(id="wh-1") if webhook is ... else webhook
return (
patch(f"{_PATH}.get_graph", new=AsyncMock(return_value=graph)),
Expand All @@ -41,12 +42,12 @@ def _patches(*, graph, webhook=..., feedback=None, preset=None):
patch(f"{_PATH}.db.create_preset", new=AsyncMock(return_value=preset)),
patch(
f"{_PATH}.experts_db.resolve_expert_for_graph",
new=AsyncMock(return_value=None),
new=AsyncMock(return_value=graph_expert),
),
)


async def _setup():
async def _setup(*, expert_id=None):
return await setup_triggered_preset(
user_id=_USER,
graph_id="graph-1",
Expand All @@ -55,6 +56,7 @@ async def _setup():
description="",
trigger_config={"repo": "owner/repo"},
agent_credentials={},
expert_id=expert_id,
)


Expand All @@ -70,6 +72,60 @@ async def test_creates_preset_on_success():
create_mock.assert_awaited_once()


@pytest.mark.asyncio
async def test_setup_prefers_session_expert_over_graph_match():
"""A trigger created inside an expert chat requests that expert directly;
create_preset performs the atomic active-owner validation."""
preset = MagicMock(id="preset-1")
p_graph, p_creds, p_webhook, p_create, p_expert = _patches(
graph=_graph(), preset=preset, graph_expert="expert-graph"
)
with (
p_graph,
p_creds,
p_webhook,
p_expert as expert_mock,
p_create as create_mock,
):
await _setup(expert_id="expert-session")
assert create_mock.call_args.kwargs["expert_id"] == "expert-session"
expert_mock.assert_not_awaited() # short-circuited by the session expert


@pytest.mark.asyncio
async def test_setup_supplied_session_expert_never_graph_reattributes():
"""A supplied session expert goes straight to the atomic create guard.
Even if it has become invalid, this layer must not replace Expert A with a
graph match for Expert B; create_preset will persist None instead."""
preset = MagicMock(id="preset-1")
p_graph, p_creds, p_webhook, p_create, p_expert = _patches(
graph=_graph(), preset=preset, graph_expert="expert-graph"
)
with (
p_graph,
p_creds,
p_webhook,
p_expert as expert_mock,
p_create as create_mock,
):
await _setup(expert_id="expert-archived")
assert create_mock.call_args.kwargs["expert_id"] == "expert-archived"
expert_mock.assert_not_awaited() # no graph-match re-attribution


@pytest.mark.asyncio
async def test_setup_falls_back_to_graph_expert_without_session_expert():
"""A plain chat / route caller passes no expert: attribution falls back to
the graph's unique expert match — unchanged behaviour."""
preset = MagicMock(id="preset-1")
p_graph, p_creds, p_webhook, p_create, p_expert = _patches(
graph=_graph(), preset=preset, graph_expert="expert-graph"
)
with p_graph, p_creds, p_webhook, p_expert, p_create as create_mock:
await _setup()
assert create_mock.call_args.kwargs["expert_id"] == "expert-graph"


@pytest.mark.asyncio
async def test_graph_not_found_raises():
p_graph, p_creds, p_webhook, p_create, p_expert = _patches(graph=None)
Expand Down
57 changes: 54 additions & 3 deletions autogpt_platform/backend/backend/copilot/db.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from pydantic import BaseModel

from backend.data import db
from backend.data.expert_attribution import resolve_attributable_expert
from backend.util.json import SafeJson, sanitize_string

from .model import (
Expand Down Expand Up @@ -276,7 +277,58 @@ async def create_chat_session(
metadata: ChatSessionMetadata | None = None,
expert_id: str | None = None,
) -> ChatSessionInfo:
"""Create a new chat session in the database."""
"""Create a chat session, atomically validating expert attribution."""
requested_expert_id = expert_id
if requested_expert_id:
async with db.transaction() as tx:
expert_id = await resolve_attributable_expert(
tx,
user_id,
requested_expert_id,
lock_for_update=True,
)
prisma_session = await PrismaChatSession.prisma(tx).create(
data=_chat_session_create_input(
session_id=session_id,
user_id=user_id,
organization_id=organization_id,
team_id=team_id,
metadata=metadata,
expert_id=expert_id,
)
)
if expert_id is None:
logger.warning(
"Ignoring inactive/unowned expert %s while creating chat "
"session %s for user %s",
requested_expert_id,
session_id,
user_id,
)
return ChatSessionInfo.from_db(prisma_session)

prisma_session = await PrismaChatSession.prisma().create(
data=_chat_session_create_input(
session_id=session_id,
user_id=user_id,
organization_id=organization_id,
team_id=team_id,
metadata=metadata,
expert_id=None,
)
)
return ChatSessionInfo.from_db(prisma_session)


def _chat_session_create_input(
*,
session_id: str,
user_id: str,
organization_id: str | None,
team_id: str | None,
metadata: ChatSessionMetadata | None,
expert_id: str | None,
) -> ChatSessionCreateInput:
data = ChatSessionCreateInput(
id=session_id,
userId=user_id,
Expand All @@ -289,8 +341,7 @@ async def create_chat_session(
**({"expertId": expert_id} if expert_id else {}),
metadata=SafeJson((metadata or ChatSessionMetadata()).model_dump()),
)
prisma_session = await PrismaChatSession.prisma().create(data=data)
return ChatSessionInfo.from_db(prisma_session)
return data


async def update_chat_session(
Expand Down
Loading
Loading