Skip to content
Open
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
31 changes: 31 additions & 0 deletions backend/alembic/versions/add_agent_bootstrap_fields.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""Add bootstrap_content + capability_bullets to agent templates.

Revision ID: add_agent_bootstrap_fields
Revises: increase_api_key_length
Create Date: 2026-04-23

Supports the Talent Market (capability_bullets fuel the template cards) and
the per-user onboarding ritual (bootstrap_content is the founder-facing
system prompt). The per-agent Agent.bootstrapped flag that earlier drafts
carried has been dropped in favour of the per-user agent_user_onboardings
junction table — see the add_agent_user_onboardings migration.
"""
from typing import Sequence, Union

from alembic import op


revision: str = 'add_agent_bootstrap_fields'
down_revision: Union[str, None] = 'increase_api_key_length'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.execute("ALTER TABLE agent_templates ADD COLUMN IF NOT EXISTS capability_bullets JSON DEFAULT '[]'::json")
op.execute("ALTER TABLE agent_templates ADD COLUMN IF NOT EXISTS bootstrap_content TEXT")


def downgrade() -> None:
op.execute("ALTER TABLE agent_templates DROP COLUMN IF EXISTS bootstrap_content")
op.execute("ALTER TABLE agent_templates DROP COLUMN IF EXISTS capability_bullets")
58 changes: 58 additions & 0 deletions backend/alembic/versions/add_agent_user_onboardings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Per-(user, agent) onboarding junction table + drop legacy bootstrapped flag.

Revision ID: add_agent_user_onboardings
Revises: add_tenant_default_model
Create Date: 2026-04-24

A row in agent_user_onboardings records that a user has been onboarded to a
specific agent. Its presence is the authoritative signal that onboarding
should NOT fire again for that pair — regardless of whether the user
actually finished the introduction flow.

Backfill: every (agent_id, user_id) pair that has any historical chat message
is inserted with onboarded_at = the earliest message. Existing users thus
never get retroactively re-onboarded.

Also drops the short-lived Agent.bootstrapped column that an earlier draft
of this feature introduced — the per-user model replaces it entirely. The
drop is idempotent so fresh installs (which no longer add the column in
add_agent_bootstrap_fields) aren't affected.
"""
from typing import Sequence, Union

from alembic import op


revision: str = 'add_agent_user_onboardings'
down_revision: Union[str, None] = 'add_tenant_default_model'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
op.execute("""
CREATE TABLE IF NOT EXISTS agent_user_onboardings (
agent_id UUID NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
onboarded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (agent_id, user_id)
)
""")

# Backfill from chat history: any pair that has ever exchanged messages is
# considered already onboarded — don't re-greet established relationships.
op.execute("""
INSERT INTO agent_user_onboardings (agent_id, user_id, onboarded_at)
SELECT agent_id, user_id, MIN(created_at)
FROM chat_messages
WHERE agent_id IS NOT NULL AND user_id IS NOT NULL
GROUP BY agent_id, user_id
ON CONFLICT DO NOTHING
""")

# Clean up the abandoned per-agent flag from the previous design iteration.
op.execute("ALTER TABLE agents DROP COLUMN IF EXISTS bootstrapped")


def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS agent_user_onboardings")
46 changes: 46 additions & 0 deletions backend/alembic/versions/add_tenant_default_model.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Add Tenant.default_model_id + backfill per-tenant to earliest enabled model.

Revision ID: add_tenant_default_model
Revises: add_agent_bootstrap_fields
Create Date: 2026-04-23

Each tenant gets a default_model_id pointing at its first enabled LLM model
(by created_at ascending). Tenants with no enabled models stay NULL; the admin
picks one when they finally add a model (handled at the API layer).
"""
from typing import Sequence, Union

from alembic import op


revision: str = 'add_tenant_default_model'
down_revision: Union[str, None] = 'add_agent_bootstrap_fields'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
# Add the nullable FK column. ON DELETE SET NULL — if a model is deleted,
# tenants that pointed at it revert to "no default."
op.execute("""
ALTER TABLE tenants
ADD COLUMN IF NOT EXISTS default_model_id UUID
REFERENCES llm_models(id) ON DELETE SET NULL
""")

# Backfill: for each tenant, pick its earliest-created enabled model.
op.execute("""
UPDATE tenants t
SET default_model_id = m.id
FROM (
SELECT DISTINCT ON (tenant_id) tenant_id, id
FROM llm_models
WHERE enabled = TRUE AND tenant_id IS NOT NULL
ORDER BY tenant_id, created_at ASC
) m
WHERE t.id = m.tenant_id AND t.default_model_id IS NULL
""")


def downgrade() -> None:
op.execute("ALTER TABLE tenants DROP COLUMN IF EXISTS default_model_id")
56 changes: 47 additions & 9 deletions backend/app/api/agents.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,11 +127,41 @@ async def list_templates(
"soul_template": t.soul_template,
"default_skills": t.default_skills,
"default_autonomy_policy": t.default_autonomy_policy,
"capability_bullets": t.capability_bullets or [],
"has_bootstrap": bool(t.bootstrap_content),
}
for t in templates
]


async def _agent_to_out(
db: AsyncSession,
agent: Agent,
viewer_id: uuid.UUID,
) -> AgentOut:
"""Serialize one agent with ``onboarded_for_me`` for the given viewer."""
from app.services.onboarding import is_onboarded
model = AgentOut.model_validate(agent)
model.onboarded_for_me = await is_onboarded(db, agent.id, viewer_id)
return model


async def _agents_to_out(
db: AsyncSession,
agents: list[Agent],
viewer_id: uuid.UUID,
) -> list[AgentOut]:
"""List variant that fetches all junction rows in one query."""
from app.services.onboarding import onboarded_agent_ids
onboarded = await onboarded_agent_ids(db, viewer_id, [a.id for a in agents])
out: list[AgentOut] = []
for a in agents:
model = AgentOut.model_validate(a)
model.onboarded_for_me = a.id in onboarded
out.append(model)
return out


@router.get("/", response_model=list[AgentOut])
async def list_agents(
tenant_id: uuid.UUID | None = None,
Expand All @@ -153,7 +183,7 @@ async def list_agents(
needs_flush = True
if needs_flush:
await db.commit()
return [AgentOut.model_validate(a) for a in agents]
return await _agents_to_out(db, list(agents), current_user.id)

# agent_admin sees their own created agents + permitted
# member sees only permitted
Expand Down Expand Up @@ -188,7 +218,7 @@ async def list_agents(
needs_flush = True
if needs_flush:
await db.commit()
return [AgentOut.model_validate(a) for a in agents]
return await _agents_to_out(db, list(agents), current_user.id)


@router.post("/", status_code=status.HTTP_201_CREATED)
Expand Down Expand Up @@ -220,6 +250,7 @@ async def create_agent(
default_min_poll = 5
default_webhook_rate = 5
default_heartbeat_interval = 240 # model default
tenant_default_model_id = None
if target_tenant_id:
from app.models.tenant import Tenant
tenant_result = await db.execute(select(Tenant).where(Tenant.id == target_tenant_id))
Expand All @@ -229,10 +260,14 @@ async def create_agent(
default_max_triggers = tenant.default_max_triggers or 20
default_min_poll = tenant.min_poll_interval_floor or 5
default_webhook_rate = tenant.max_webhook_rate_ceiling or 5
tenant_default_model_id = tenant.default_model_id
# Enforce heartbeat floor: new agents must respect company minimum
if tenant.min_heartbeat_interval_minutes and tenant.min_heartbeat_interval_minutes > default_heartbeat_interval:
default_heartbeat_interval = tenant.min_heartbeat_interval_minutes

# If the caller didn't pick a model, fall back to the tenant's default.
effective_primary_model_id = data.primary_model_id or tenant_default_model_id
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Skip disabled tenant defaults when assigning primary model

Agent creation now blindly applies tenant.default_model_id when the caller omits primary_model_id, but defaults are not cleared when a model is later disabled. In that state, newly created agents inherit a disabled model and then fail chat model resolution (the websocket path drops disabled primaries), producing immediately unusable agents until manually reconfigured.

Useful? React with 👍 / 👎.


agent = Agent(
name=data.name,
role_description=data.role_description,
Expand All @@ -241,7 +276,7 @@ async def create_agent(
creator_id=current_user.id,
tenant_id=target_tenant_id,
agent_type=data.agent_type or "native",
primary_model_id=data.primary_model_id,
primary_model_id=effective_primary_model_id,
fallback_model_id=data.fallback_model_id,
max_tokens_per_day=data.max_tokens_per_day,
max_tokens_per_month=data.max_tokens_per_month,
Expand Down Expand Up @@ -290,7 +325,8 @@ async def create_agent(
agent.api_key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
agent.status = "idle"
await db.commit()
out = AgentOut.model_validate(agent).model_dump()
out_model = await _agent_to_out(db, agent, current_user.id)
out = out_model.model_dump()
out["api_key"] = raw_key # Return once on creation
return out

Expand Down Expand Up @@ -340,7 +376,7 @@ async def create_agent(
await agent_manager.start_container(db, agent)
await db.flush()

return AgentOut.model_validate(agent)
return await _agent_to_out(db, agent, current_user.id)


@router.get("/{agent_id}")
Expand All @@ -354,7 +390,8 @@ async def get_agent(
# Lazy reset token counters
if await _lazy_reset_token_counters(agent, db):
await db.commit()
out = AgentOut.model_validate(agent).model_dump()
out_model = await _agent_to_out(db, agent, current_user.id)
out = out_model.model_dump()
out["access_level"] = access_level

# Resolve creator username (one extra query, only on detail page).
Expand Down Expand Up @@ -549,7 +586,8 @@ async def update_agent(
p.avatar_url = agent.avatar_url
await db.flush()

out = AgentOut.model_validate(agent).model_dump()
out_model = await _agent_to_out(db, agent, current_user.id)
out = out_model.model_dump()
if clamped_fields:
out["_clamped_fields"] = clamped_fields
return out
Expand Down Expand Up @@ -672,7 +710,7 @@ async def start_agent(
from app.services.agent_manager import agent_manager
await agent_manager.start_container(db, agent)
await db.flush()
return AgentOut.model_validate(agent)
return await _agent_to_out(db, agent, current_user.id)


@router.post("/{agent_id}/stop", response_model=AgentOut)
Expand All @@ -689,7 +727,7 @@ async def stop_agent(
from app.services.agent_manager import agent_manager
await agent_manager.stop_container(agent)
await db.flush()
return AgentOut.model_validate(agent)
return await _agent_to_out(db, agent, current_user.id)


# ─── Agent-Level Approvals ──────────────────────────────
Expand Down
8 changes: 5 additions & 3 deletions backend/app/api/chat_sessions.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,12 @@ async def list_sessions(
total_counts[row[0]] = int(row[2] or 0)

for session in sessions:
user_msg_count = user_msg_counts.get(str(session.id), 0)
if user_msg_count == 0:
continue # hide empty or orphan sessions
# Hide truly empty / orphan sessions. Onboarding sessions have zero
# user messages (the agent greets first) but do have assistant
# turns, so count ALL messages here — not just user ones.
count = total_counts.get(str(session.id), 0)
if count == 0:
continue
out.append(SessionOut(
id=str(session.id),
agent_id=str(session.agent_id),
Expand Down
36 changes: 36 additions & 0 deletions backend/app/api/enterprise.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,9 +176,45 @@ async def add_llm_model(
)
db.add(model)
await db.flush()

# First enabled model for a tenant becomes that tenant's default.
# Admins can later reassign via PATCH /llm-models/{id}/set-default.
if model.tenant_id and model.enabled:
from app.models.tenant import Tenant
t_result = await db.execute(select(Tenant).where(Tenant.id == model.tenant_id))
tenant = t_result.scalar_one_or_none()
if tenant and tenant.default_model_id is None:
tenant.default_model_id = model.id

return LLMModelOut.model_validate(model)


@router.post("/llm-models/{model_id}/set-default", status_code=status.HTTP_204_NO_CONTENT)
async def set_default_llm_model(
model_id: uuid.UUID,
current_user: User = Depends(get_current_admin),
db: AsyncSession = Depends(get_db),
):
"""Mark this model as the tenant's default for new agents."""
result = await db.execute(select(LLMModel).where(LLMModel.id == model_id))
model = result.scalar_one_or_none()
Comment on lines +199 to +200
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce tenant ownership before setting default model

set_default_llm_model accepts any model_id and immediately updates that model's tenant record, but it never checks that an org_admin belongs to the same tenant as the target model. In practice, any org admin who learns another tenant's model UUID can change that tenant's default model, which is a cross-tenant authorization break; add a tenant-scope guard before writing tenant.default_model_id.

Useful? React with 👍 / 👎.

if not model:
raise HTTPException(status_code=404, detail="Model not found")
if not model.tenant_id:
raise HTTPException(status_code=400, detail="Model is not tenant-scoped")
if not model.enabled:
raise HTTPException(status_code=400, detail="Model is disabled")

from app.models.tenant import Tenant
t_result = await db.execute(select(Tenant).where(Tenant.id == model.tenant_id))
tenant = t_result.scalar_one_or_none()
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")

tenant.default_model_id = model.id
await db.commit()


@router.delete("/llm-models/{model_id}", status_code=status.HTTP_204_NO_CONTENT)
async def remove_llm_model(
model_id: uuid.UUID,
Expand Down
19 changes: 19 additions & 0 deletions backend/app/api/tenants.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class TenantOut(BaseModel):
sso_enabled: bool = False
sso_domain: str | None = None
a2a_async_enabled: bool = False
default_model_id: uuid.UUID | None = None
created_at: datetime | None = None

model_config = {"from_attributes": True}
Expand Down Expand Up @@ -412,6 +413,24 @@ async def list_tenants(
return [TenantOut.model_validate(t) for t in result.scalars().all()]


@router.get("/me", response_model=TenantOut)
async def get_my_tenant(
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""Return the current user's own tenant. Any authenticated member can read
this — the wizard and the chat model switcher need default_model_id, which
shouldn't require admin privileges.
"""
if not current_user.tenant_id:
raise HTTPException(status_code=404, detail="User is not in a tenant")
result = await db.execute(select(Tenant).where(Tenant.id == current_user.tenant_id))
tenant = result.scalar_one_or_none()
if not tenant:
raise HTTPException(status_code=404, detail="Tenant not found")
return TenantOut.model_validate(tenant)


@router.get("/{tenant_id}", response_model=TenantOut)
async def get_tenant(
tenant_id: uuid.UUID,
Expand Down
Loading