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
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,20 @@ export const ArtifactDeleteAllDialog: React.FC = () => {
return null;
}

const hasProjectArtifacts = artifacts.some(artifact => artifact.source === "project");
const projectArtifactsCount = artifacts.filter(artifact => artifact.source === "project").length;
const regularArtifactsCount = artifacts.length - projectArtifactsCount;
// Check for read-only artifacts (project or agent_default)
const isReadOnlyArtifact = (artifact: { source?: string }) => artifact.source === "project" || artifact.source === "agent_default";

const hasReadOnlyArtifacts = artifacts.some(isReadOnlyArtifact);
const readOnlyArtifactsCount = artifacts.filter(isReadOnlyArtifact).length;
const regularArtifactsCount = artifacts.length - readOnlyArtifactsCount;

const getDescription = () => {
if (hasProjectArtifacts && regularArtifactsCount === 0) {
// All are project artifacts
return `${artifacts.length === 1 ? "This file" : `All ${artifacts.length} files`} will be removed from this chat session. ${artifacts.length === 1 ? "The file" : "These files"} will remain in ${artifacts.length === 1 ? "the" : "their"} project${artifacts.length === 1 ? "" : "s"}.`;
} else if (hasProjectArtifacts && regularArtifactsCount > 0) {
// Mixed: some project, some regular
return `${regularArtifactsCount} ${regularArtifactsCount === 1 ? "file" : "files"} will be permanently deleted. ${projectArtifactsCount} project ${projectArtifactsCount === 1 ? "file" : "files"} will be removed from this chat but will remain in ${projectArtifactsCount === 1 ? "the" : "their"} project${projectArtifactsCount === 1 ? "" : "s"}.`;
if (hasReadOnlyArtifacts && regularArtifactsCount === 0) {
// All are read-only artifacts (project or agent_default)
return `${artifacts.length === 1 ? "This file" : `All ${artifacts.length} files`} will be removed from this chat session. ${artifacts.length === 1 ? "The file" : "These files"} will remain available as ${artifacts.length === 1 ? "a default" : "defaults"}.`;
} else if (hasReadOnlyArtifacts && regularArtifactsCount > 0) {
// Mixed: some read-only, some regular
return `${regularArtifactsCount} ${regularArtifactsCount === 1 ? "file" : "files"} will be permanently deleted. ${readOnlyArtifactsCount} read-only ${readOnlyArtifactsCount === 1 ? "file" : "files"} will be removed from this chat but will remain available.`;
} else {
// All are regular artifacts
return `${artifacts.length === 1 ? "One file" : `All ${artifacts.length} files`} will be permanently deleted.`;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ export const ArtifactPanel: React.FC = () => {
return artifacts ? [...artifacts].sort(sortFunctions[sortOption]) : [];
}, [artifacts, artifactsLoading, sortOption]);

// Check if there are any deletable artifacts (not from projects)
// Check if there are any deletable artifacts (not from projects or agent defaults)
const hasDeletableArtifacts = useMemo(() => {
return sortedArtifacts.some(artifact => artifact.source !== "project");
return sortedArtifacts.some(artifact => artifact.source !== "project" && artifact.source !== "agent_default");
}, [sortedArtifacts]);

const header = useMemo(() => {
Expand Down Expand Up @@ -106,7 +106,7 @@ export const ArtifactPanel: React.FC = () => {
isPreview={true}
isExpanded={isPreviewInfoExpanded}
setIsExpanded={setIsPreviewInfoExpanded}
onDelete={previewArtifact.source === "project" ? undefined : () => openDeleteModal(previewArtifact)}
onDelete={previewArtifact.source === "project" || previewArtifact.source === "agent_default" ? undefined : () => openDeleteModal(previewArtifact)}
onDownload={() => onDownload(previewArtifact)}
/>
</div>
Expand All @@ -126,7 +126,7 @@ export const ArtifactPanel: React.FC = () => {
</div>
<div>
<span className="text-secondary-foreground">Type:</span>
<div>{previewArtifact.mime_type || 'Unknown'}</div>
<div>{previewArtifact.mime_type || "Unknown"}</div>
</div>
</div>
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,8 +52,10 @@ export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
const context = props.context || "chat";
const isStreaming = props.isStreaming;

// Check if this artifact is from a project (should not be deletable)
// Check if this artifact is from a project or agent default (should not be deletable)
const isProjectArtifact = artifact?.source === "project";
const isAgentDefaultArtifact = artifact?.source === "agent_default";
const isReadOnlyArtifact = isProjectArtifact || isAgentDefaultArtifact;

// Extract version from URI if available
const version = useMemo(() => {
Expand Down Expand Up @@ -326,8 +328,8 @@ export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
return {
onInfo: handleInfoClick,
onDownload: props.status === "completed" ? handleDownloadClick : undefined,
// Hide delete button for artifacts with source="project" (they came from project files)
onDelete: artifact && props.status === "completed" && !isProjectArtifact ? handleDeleteClick : undefined,
// Hide delete button for artifacts with source="project" or "agent_default" (read-only artifacts)
onDelete: artifact && props.status === "completed" && !isReadOnlyArtifact ? handleDeleteClick : undefined,
};
} else {
// In chat context, show preview, download, and info actions
Expand All @@ -338,7 +340,7 @@ export const ArtifactMessage: React.FC<ArtifactMessageProps> = props => {
onInfo: handleInfoClick,
};
}
}, [props.status, context, handleDownloadClick, artifact, handleDeleteClick, handleInfoClick, handlePreviewClick, isProjectArtifact]);
}, [props.status, context, handleDownloadClick, artifact, handleDeleteClick, handleInfoClick, handlePreviewClick, isReadOnlyArtifact]);

// Get description from global artifacts instead of message parts
const artifactFromGlobal = useMemo(() => artifacts.find(art => art.filename === props.name), [artifacts, props.name]);
Expand Down
19 changes: 16 additions & 3 deletions client/webui/frontend/src/lib/hooks/useArtifacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ const isIntermediateWebContentArtifact = (filename: string | undefined): boolean
return filename.startsWith("web_content_");
};

export const useArtifacts = (sessionId?: string): UseArtifactsReturn => {
export const useArtifacts = (sessionId?: string, agentName?: string): UseArtifactsReturn => {
const { activeProject } = useProjectContext();
const [artifacts, setArtifacts] = useState<ArtifactInfo[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
Expand All @@ -36,17 +36,30 @@ export const useArtifacts = (sessionId?: string): UseArtifactsReturn => {

try {
let endpoint: string;
const params = new URLSearchParams();

if (sessionId && sessionId.trim() && sessionId !== "null" && sessionId !== "undefined") {
endpoint = `/api/v1/artifacts/${sessionId}`;
} else if (activeProject?.id) {
endpoint = `/api/v1/artifacts/null?project_id=${activeProject.id}`;
endpoint = `/api/v1/artifacts/null`;
params.append("project_id", activeProject.id);
} else {
setArtifacts([]);
setIsLoading(false);
return;
}

// Add agent_name parameter to include agent's default artifacts
if (agentName) {
params.append("agent_name", agentName);
}

// Append query parameters if any
const queryString = params.toString();
if (queryString) {
endpoint += `?${queryString}`;
}

const data: ArtifactInfo[] = await api.webui.get(endpoint);
// Filter out intermediate web content artifacts from deep research
const filteredData = data.filter(artifact => !isIntermediateWebContentArtifact(artifact.filename));
Expand All @@ -62,7 +75,7 @@ export const useArtifacts = (sessionId?: string): UseArtifactsReturn => {
} finally {
setIsLoading(false);
}
}, [sessionId, activeProject?.id]);
}, [sessionId, activeProject?.id, agentName]);

useEffect(() => {
fetchArtifacts();
Expand Down
3 changes: 2 additions & 1 deletion client/webui/frontend/src/lib/providers/ChatProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,8 @@
const { agents, agentNameMap: agentNameDisplayNameMap, error: agentsError, isLoading: agentsLoading, refetch: agentsRefetch } = useAgentCards();

// Chat Side Panel State
const { artifacts, isLoading: artifactsLoading, refetch: artifactsRefetch, setArtifacts } = useArtifacts(sessionId);
// Pass selectedAgentName to include agent's default artifacts when agent changes
const { artifacts, isLoading: artifactsLoading, refetch: artifactsRefetch, setArtifacts } = useArtifacts(sessionId, selectedAgentName);

// Title Generation
const { generateTitle } = useTitleGeneration();
Expand Down Expand Up @@ -1522,7 +1523,7 @@
}, 100);
}
},
[

Check warning on line 1526 in client/webui/frontend/src/lib/providers/ChatProvider.tsx

View workflow job for this annotation

GitHub Actions / Build and Test UI

React Hook useCallback has a missing dependency: 'autoTitleGenerationEnabled'. Either include it or remove the dependency array
addNotification,
closeCurrentEventSource,
artifactsRefetch,
Expand Down
136 changes: 134 additions & 2 deletions src/solace_agent_mesh/agent/adk/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,12 +46,22 @@
TestInMemoryArtifactService = None


# Constants for agent-level default artifacts
AGENT_DEFAULTS_USER_ID = "__agent_defaults__"


class ScopedArtifactServiceWrapper(BaseArtifactService):
"""
A wrapper for an artifact service that transparently applies a configured scope.
This ensures all artifact operations respect either 'namespace' or 'app' scoping
without requiring changes at the call site. It dynamically checks the component's
configuration on each call to support test-specific overrides.

Additionally, this wrapper supports agent-level default artifacts that are:
- Loaded at agent startup from the `default_artifacts` configuration
- Stored in a special scope using AGENT_DEFAULTS_USER_ID
- Accessible to all users with access to the agent (read-only)
- Used as a fallback when an artifact is not found in the user's session
"""

def __init__(
Expand All @@ -68,6 +78,8 @@ def __init__(
"""
self.wrapped_service = wrapped_service
self.component = component
# Cache of default artifact filenames for quick lookup
self._default_artifact_filenames: Optional[set] = None

def _get_scoped_app_name(self, app_name: str) -> str:
"""
Expand All @@ -86,6 +98,28 @@ def _get_scoped_app_name(self, app_name: str) -> str:
# typically the agent_name or gateway_id.
return app_name

def _has_default_artifacts(self) -> bool:
"""Check if the agent has default artifacts configured."""
default_artifacts = self.component.get_config("default_artifacts", [])
return bool(default_artifacts)

def _get_default_artifact_filenames(self) -> set:
"""Get the set of default artifact filenames for quick lookup."""
if self._default_artifact_filenames is None:
default_artifacts = self.component.get_config("default_artifacts", [])
self._default_artifact_filenames = {
artifact.get("filename") for artifact in default_artifacts if artifact.get("filename")
}
return self._default_artifact_filenames

def _is_default_artifact(self, filename: str) -> bool:
"""Check if a filename corresponds to a default artifact."""
return filename in self._get_default_artifact_filenames()

def _get_agent_name(self) -> str:
"""Get the agent name from the component."""
return self.component.get_config("agent_name", self.component.agent_name)

@override
async def save_artifact(
self,
Expand All @@ -97,6 +131,28 @@ async def save_artifact(
artifact: adk_types.Part,
) -> int:
scoped_app_name = self._get_scoped_app_name(app_name)

# Allow saving to agent defaults scope (used during agent initialization)
if user_id == AGENT_DEFAULTS_USER_ID:
return await self.wrapped_service.save_artifact(
app_name=scoped_app_name,
user_id=user_id,
session_id=session_id,
filename=filename,
artifact=artifact,
)

# For regular users, check if they're trying to overwrite a default artifact
# Users can create their own version of a default artifact in their session
# (this allows overrides), but we log a warning for visibility
if self._is_default_artifact(filename):
log.debug(
"User '%s' is saving artifact '%s' which shadows a default artifact. "
"The user's version will take precedence in their session.",
user_id,
filename,
)

return await self.wrapped_service.save_artifact(
app_name=scoped_app_name,
user_id=user_id,
Expand All @@ -116,28 +172,104 @@ async def load_artifact(
version: Optional[int] = None,
) -> Optional[adk_types.Part]:
scoped_app_name = self._get_scoped_app_name(app_name)
return await self.wrapped_service.load_artifact(

# First, try to load from the user's session
result = await self.wrapped_service.load_artifact(
app_name=scoped_app_name,
user_id=user_id,
session_id=session_id,
filename=filename,
version=version,
)

if result is not None:
return result

# If not found and we have default artifacts configured, try the agent defaults
if self._has_default_artifacts() and user_id != AGENT_DEFAULTS_USER_ID:
agent_name = self._get_agent_name()
log.debug(
"Artifact '%s' not found in user session, checking agent defaults for agent '%s'.",
filename,
agent_name,
)
result = await self.wrapped_service.load_artifact(
app_name=scoped_app_name,
user_id=AGENT_DEFAULTS_USER_ID,
session_id=agent_name,
filename=filename,
version=version,
)
if result is not None:
log.debug(
"Loaded artifact '%s' from agent defaults for agent '%s'.",
filename,
agent_name,
)

return result

@override
async def list_artifact_keys(
self, *, app_name: str, user_id: str, session_id: str
) -> List[str]:
scoped_app_name = self._get_scoped_app_name(app_name)
return await self.wrapped_service.list_artifact_keys(

# Get user's session artifacts
user_artifacts = await self.wrapped_service.list_artifact_keys(
app_name=scoped_app_name, user_id=user_id, session_id=session_id
)

# If we have default artifacts and this is not the defaults user, include them
if self._has_default_artifacts() and user_id != AGENT_DEFAULTS_USER_ID:
agent_name = self._get_agent_name()
default_artifacts = await self.wrapped_service.list_artifact_keys(
app_name=scoped_app_name,
user_id=AGENT_DEFAULTS_USER_ID,
session_id=agent_name,
)

# Merge lists (user artifacts take precedence, so we use a set)
all_artifacts = list(set(user_artifacts + default_artifacts))
return all_artifacts

return user_artifacts

@override
async def delete_artifact(
self, *, app_name: str, user_id: str, session_id: str, filename: str
) -> None:
scoped_app_name = self._get_scoped_app_name(app_name)

# Prevent users from deleting default artifacts
if user_id != AGENT_DEFAULTS_USER_ID and self._is_default_artifact(filename):
# Check if the user has their own version of this artifact
user_artifact = await self.wrapped_service.load_artifact(
app_name=scoped_app_name,
user_id=user_id,
session_id=session_id,
filename=filename,
)
if user_artifact is None:
# User is trying to delete a default artifact they don't own
log.warning(
"User '%s' attempted to delete default artifact '%s'. "
"Default artifacts are read-only.",
user_id,
filename,
)
raise PermissionError(
f"Cannot delete default artifact '{filename}'. "
"Default artifacts are read-only."
)
# User has their own version, allow deletion of their version
log.debug(
"User '%s' is deleting their own version of artifact '%s' "
"(default artifact will still be accessible).",
user_id,
filename,
)

await self.wrapped_service.delete_artifact(
app_name=scoped_app_name,
user_id=user_id,
Expand Down
37 changes: 37 additions & 0 deletions src/solace_agent_mesh/agent/sac/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,36 @@ class McpProcessingConfig(SamConfigBase):
)


class DefaultArtifactConfig(SamConfigBase):
"""Configuration for a default artifact to be pre-loaded when the agent starts.

Default artifacts are loaded at agent startup and made available to all users
with access to the agent. They are read-only for users and stored in a special
agent-level scope that is accessible from all sessions.
"""

path: str = Field(
...,
description="Path to the artifact file. Supports local filesystem paths, "
"environment variable substitution (e.g., ${SAM_PROJECT_ROOT}/data/file.pdf), "
"and remote sources (s3://, https://).",
)
filename: str = Field(
...,
description="Filename to use when storing the artifact. This is the name "
"that will be used to reference the artifact in tools and prompts.",
)
description: Optional[str] = Field(
default=None,
description="Human-readable description of the artifact's purpose and contents.",
)
mime_type: Optional[str] = Field(
default=None,
description="MIME type of the artifact. If not specified, it will be "
"auto-detected from the file extension.",
)


class ArtifactServiceConfig(SamConfigBase):
"""Configuration for the ADK Artifact Service."""

Expand Down Expand Up @@ -457,6 +487,13 @@ class SamAgentAppConfig(SamConfigBase):
default_factory=McpProcessingConfig,
description="Configuration for intelligent processing of MCP tool responses.",
)
default_artifacts: List[DefaultArtifactConfig] = Field(
default_factory=list,
description="List of artifacts to pre-load when the agent starts. "
"These artifacts are available to all users with access to the agent "
"and are read-only. They are stored in a special agent-level scope "
"that is accessible from all sessions.",
)


class SamAgentApp(SamAppBase):
Expand Down
Loading
Loading