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 @@ -57,3 +57,10 @@ Workbench passes standard onRefresh/onSelectAll/allSelected/partiallySelected to
## Workbench footer alignment

The standard Workbench picker path uses ResourceDrawer's default Close/Confirm labels and footer buttons. It explicitly clears footerLeading, closeText and confirmText after spreading picker options; do not reintroduce Workbench-specific Cancel/Confirm-selection labels or footer settings/default actions. Workbench Knowledge hides retrieval/default controls in drawer presentation while retaining saved retrieval_config; the legacy knowledge Modal still owns its existing controls. SkillPicker no longer exposes loadDefaults or restore state, and WorkbenchPage supplies only the existing committed mounts. Resource pagination defaults to 10 and offers exactly 10/20/50; actual slicing and repository requests must use the controlled value. Removing a UI action does not reset saved settings or change confirmation/cancellation semantics.


## Workbench discovery lifecycle

Workbench renders the composer independently of candidate catalogs and execution-readiness requests. AgentPicker owns an open/active-Mine-gated, identity-scoped query when candidates are not supplied; externally supplied candidates remain supported. Repository queries require the Repository tab. Hydrate saved selection only when initial candidates arrive, without replacing an edited draft on refresh; identity changes discard the prior identity's candidate draft. Load knowledge capabilities and groups on drawer opening, keep capability/catalog errors local, and disable confirmation until required reads settle. Saved-resource validation uses pinned Agent previews and Skill detail-by-ID reads, not full candidate catalogs. Name-only knowledge restoration may use ID placeholders until explicit discovery. Sending still requires execution readiness; discovery must never unmount the composer or submit a message.

SkillPicker opts into GET /skills?view=mount on both normal loading and post-install refresh. This projection retains display/permission fields and configuration schema/defaults, omits content and tool_ids, and must not change the default full list used by editors. Confirmation still sends only skill_id and user config_values; execution resolves authoritative content and tools on the backend.
12 changes: 10 additions & 2 deletions backend/management/api/skill/skill.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from contracts.skill import InstallSkillsRequest
import logging
from http import HTTPStatus
from typing import Any, Dict, Optional
from typing import Any, Dict, Literal, Optional

from fastapi import APIRouter, File, Form, Header, HTTPException, Query, Request, UploadFile
from starlette.responses import JSONResponse
Expand Down Expand Up @@ -62,6 +62,9 @@ async def list_skills(
tenant_id: Optional[str] = Query(None, description="Tenant ID for super admin to query specific tenant's skills"),
authorization: Optional[str] = Header(None),
request: Request = None,
view: Literal["full", "mount"] = Query(
"full", description="Use mount for metadata without Skill content or tool associations"
),
) -> JSONResponse:
"""List all available skills for the current tenant (or a specific tenant for super admin)."""
try:
Expand All @@ -70,13 +73,18 @@ async def list_skills(
inventory_tenant = resource_read_tenant(request, shared=True)
effective_tenant_id = inventory_tenant or tenant_id or current_tenant_id
service = SkillService(tenant_id=effective_tenant_id)
list_options = {"for_mount": True} if view == "mount" else {}
if inventory_tenant:
fields = ("skill_id", "name", "description", "source", "tags", "update_time")
skills = [{field: skill.get(field) for field in fields} for skill in service.list_skills()]
skills = [
{field: skill.get(field) for field in fields}
for skill in service.list_skills(**list_options)
]
return JSONResponse(content={"skills": skills})
skills = service.list_visible_skills(
tenant_id=effective_tenant_id,
user_id=user_id,
**list_options,
)
return JSONResponse(content={"skills": skills})
except HTTPException:
Expand Down
18 changes: 16 additions & 2 deletions backend/management/db/skill/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from typing import Any, Dict, List, Optional

from sqlalchemy import or_, update as sa_update
from sqlalchemy.orm import defer

from contracts.constants.constants import ASSET_OWNER_TENANT_ID
from management.db.client import get_db_session
Expand Down Expand Up @@ -332,15 +333,15 @@ def _replace_skill_tool_relations(
)


def _to_dict(skill: SkillInfo) -> Dict[str, Any]:
def _to_dict(skill: SkillInfo, *, include_content: bool = True) -> Dict[str, Any]:
"""Convert SkillInfo to dict."""
return {
"skill_id": skill.skill_id,
"name": skill.skill_name,
"tenant_id": skill.tenant_id,
"description": skill.skill_description,
"tags": _normalize_skill_tags(skill.skill_tags),
"content": skill.skill_content or "",
**({"content": skill.skill_content or ""} if include_content else {}),
"config_schemas": skill.config_schemas,
"config_values": skill.config_values,
"source": skill.source,
Expand Down Expand Up @@ -386,6 +387,19 @@ def list_skills(tenant_id: str) -> List[Dict[str, Any]]:
return results


def list_skill_mount_summaries(tenant_id: str) -> List[Dict[str, Any]]:
"""Read mount metadata without loading Skill bodies or tool relations."""
with get_db_session() as session:
skills = (
session.query(SkillInfo)
.options(defer(SkillInfo.skill_content))
.filter(SkillInfo.tenant_id == tenant_id, SkillInfo.delete_flag != "Y")
.order_by(SkillInfo.create_time.desc(), SkillInfo.skill_id.desc())
.all()
)
return [_to_dict(skill, include_content=False) for skill in skills]


def list_skill_permission_summaries(tenant_id: str) -> List[Dict[str, Any]]:
"""List only the fields required to resolve skill visibility and ownership."""
with get_db_session() as session:
Expand Down
3 changes: 3 additions & 0 deletions backend/management/services/agent/preparation/inputs.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ def _materialize_runtime_skill_snapshot(
) -> List[Dict[str, Any]]:
"""Materialize frozen Skill files below the run workspace without mutating input."""
effective = copy.deepcopy(snapshot)
skill_names = [str(skill.get("name") or "") for skill in effective]
if len(skill_names) != len(set(skill_names)):
raise WorkbenchError("RUNTIME_SKILL_NAME_CONFLICT")
snapshot_root = (Path(workspace_path).resolve() / ".skill_snapshot").resolve()
tenant_root = (snapshot_root / tenant_id).resolve()
if not tenant_root.is_relative_to(snapshot_root):
Expand Down
24 changes: 23 additions & 1 deletion backend/management/services/agent/workbench.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ def _resolve_skill_mounts(
if not mounts and not published_instances:
return ()
runtime_mounts = {int(mount["skill_id"]): mount for mount in mounts}
explicit_skill_ids = set(runtime_mounts)
effective_mounts: list[Mapping[str, Any]] = []
seen_skill_ids: set[int] = set()
for instance in published_instances:
Expand All @@ -190,7 +191,9 @@ def _resolve_skill_mounts(
)
effective_mounts.extend(runtime_mounts.values())

resolved = []
# Runtime prompts, configuration and files share the Skill name namespace.
# Select complete resources before capturing files or resolving dependencies.
selected_by_name: Dict[str, tuple[int, Mapping[str, Any], Dict[str, Any], str]] = {}
role = _get_user_role(user_id) if user_id else None
groups = set(query_group_ids_by_user(user_id) or []) if user_id else set()
for mount in effective_mounts:
Expand All @@ -201,6 +204,19 @@ def _resolve_skill_mounts(
raise WorkbenchError("RUNTIME_SKILL_FORBIDDEN", status_code=403)
if resource_owner != ASSET_OWNER_TENANT_ID and user_id and not can_view_skill(skill=skill, user_id=user_id, user_role=role, user_group_ids=groups):
raise WorkbenchError("RUNTIME_SKILL_FORBIDDEN", status_code=403)
skill_name = str(skill.get("name") or skill_id)
previous = selected_by_name.get(skill_name)
if previous is not None and previous[0] != skill_id:
previous_is_explicit = previous[0] in explicit_skill_ids
current_is_explicit = skill_id in explicit_skill_ids
if previous_is_explicit == current_is_explicit:
raise WorkbenchError("RUNTIME_SKILL_NAME_CONFLICT")
if previous_is_explicit:
continue
selected_by_name[skill_name] = (skill_id, mount, skill, resource_owner)

resolved = []
for skill_id, mount, skill, resource_owner in selected_by_name.values():
instance = next((item for item in published_instances if item.get("skill_id") == skill_id), {})
config_values = resolve_skill_config(
skill.get("config_schemas") or [],
Expand All @@ -223,10 +239,16 @@ def _resolve_skill_mounts(
)
tool_ids = sorted({tool_id for skill in resolved for tool_id in skill.tool_ids})
definitions = {int(tool["tool_id"]): deepcopy(tool) for tool in query_tools_by_ids(tool_ids)} if tool_ids else {}
tool_names: Dict[str, int] = {}
for tool_id in tool_ids:
tool = definitions.get(tool_id)
if not tool or tool.get("is_available") is not True or str(tool.get("author")) not in (tenant_id, ASSET_OWNER_TENANT_ID):
raise WorkbenchError("RUNTIME_SKILL_DEPENDENCY_UNAVAILABLE")
tool_name = str(tool.get("name") or tool.get("class_name") or "")
if tool_name and tool_name in tool_names and tool_names[tool_name] != tool_id:
raise WorkbenchError("RUNTIME_SKILL_TOOL_CONFLICT")
if tool_name:
tool_names[tool_name] = tool_id
values: Dict[str, Any] = {}
parameter_names = {param.get("name") for param in tool.get("params") or []}
for skill in resolved:
Expand Down
17 changes: 14 additions & 3 deletions backend/management/services/skill/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,11 +309,14 @@ def _enrich_configs_from_yaml(self, skill: Dict[str, Any]) -> Dict[str, Any]:
out.pop("config_schemas", None)
return out

def list_skills(self, tenant_id: Optional[str] = None) -> List[Dict[str, Any]]:
def list_skills(
self, tenant_id: Optional[str] = None, *, for_mount: bool = False
) -> List[Dict[str, Any]]:
"""List all skills for a tenant.

Args:
tenant_id: Tenant ID for filtering skills. Uses instance tenant_id if not provided.
for_mount: Omit Skill content and tool relations while retaining configuration metadata.

Returns:
List of skill info dicts
Expand All @@ -322,7 +325,10 @@ def list_skills(self, tenant_id: Optional[str] = None) -> List[Dict[str, Any]]:
if not effective_tenant_id:
raise SkillException("tenant_id is required")
try:
skills = skill_db.list_skills(effective_tenant_id)
skills = (
skill_db.list_skill_mount_summaries(effective_tenant_id)
if for_mount else skill_db.list_skills(effective_tenant_id)
)
enriched = [self._enrich_configs_from_yaml(s) for s in skills]
return enriched
except Exception as e:
Expand All @@ -334,13 +340,18 @@ def list_visible_skills(
*,
tenant_id: Optional[str] = None,
user_id: str,
for_mount: bool = False,
) -> List[Dict[str, Any]]:
"""List skills visible to a user and attach the resolved permission."""
user_role = _get_user_role(user_id)
user_group_ids = set(query_group_ids_by_user(user_id) or [])
skills = (
self.list_skills(tenant_id=tenant_id, for_mount=True)
if for_mount else self.list_skills(tenant_id=tenant_id)
)
visible_skills = [
skill
for skill in self.list_skills(tenant_id=tenant_id)
for skill in skills
if can_view_skill(
skill=skill,
user_id=user_id,
Expand Down
3 changes: 1 addition & 2 deletions frontend/app/[locale]/newchat/assistant-ui/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

"use client";


import type { FC, ReactNode } from "react";
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
Expand Down Expand Up @@ -132,7 +131,7 @@ export const Chat: FC<ChatProps> = ({
);

if (!selectedAgent) {
if (isLoadingAgents) {
if (isLoadingAgents && !workbenchPresentation && !landingContent) {
return <AgentsLoadingState />;
}
if (workbenchPresentation || landingContent) {
Expand Down
17 changes: 17 additions & 0 deletions frontend/app/[locale]/newchat/assistant-ui/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import {
useMemo,
useEffect,
useState,
useSyncExternalStore,
type FC,
Expand Down Expand Up @@ -481,6 +482,11 @@ export const Composer: FC<ComposerProps> = ({
? { key: knowledgeAccessKey, open }
: current
);
const onKnowledgeOpenChange = workbenchPresentation?.onKnowledgeOpenChange;
useEffect(() => {
onKnowledgeOpenChange?.(knowledgeModalOpen);
return () => onKnowledgeOpenChange?.(false);
}, [knowledgeModalOpen, onKnowledgeOpenChange]);
const { data: knowledgeBases = [] } = useComposerKnowledgeBases(
conversationResources && canSelectKnowledge,
enableAidpKnowledge
Expand Down Expand Up @@ -944,6 +950,17 @@ export const Composer: FC<ComposerProps> = ({
open={knowledgeModalOpen}
value={knowledgeScope}
capabilities={knowledgeCapabilities}
onRetryCapabilities={workbenchPresentation?.onKnowledgeRetry}
capabilitiesPending={
Boolean(
workbenchPresentation?.onKnowledgeOpenChange &&
!knowledgeCapabilities &&
!workbenchPresentation?.knowledgeUnavailable
) || workbenchPresentation?.knowledgePending
}
capabilitiesUnavailable={
workbenchPresentation?.knowledgeUnavailable
}
onCancel={() => setKnowledgeModalOpen(false)}
onConfirm={async (scope, preview) => {
if (!canSelectKnowledge) return;
Expand Down
Loading
Loading