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
41 changes: 40 additions & 1 deletion flocks/config/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -349,6 +349,20 @@ class ToolOutputConfig(BaseModel):
)


class ToolFailureConfig(BaseModel):
"""Repeated tool-failure handling."""

model_config = {"populate_by_name": True}

disable_on_repeated_failure: bool = Field(
True,
alias="disableOnRepeatedFailure",
description=(
"Disable a standalone custom tool after repeated identical failures."
),
)


class EnterpriseConfig(BaseModel):
"""Enterprise configuration"""

Expand All @@ -375,6 +389,10 @@ class UIConfig(BaseModel):
max_length=256,
description="Relative path to a custom WebUI favicon stored in the user config directory.",
)
theme: Optional[Literal["light", "dark"]] = Field(
None,
description="WebUI theme preference. Persisted server-side so it survives browser/origin changes.",
)

@field_validator("display_name", mode="before")
@classmethod
Expand Down Expand Up @@ -675,6 +693,11 @@ class ConfigInfo(BaseModel):
alias="toolOutput",
description="Tool output size limits (read, truncation caps).",
)
tool_failure: Optional[ToolFailureConfig] = Field(
None,
alias="toolFailure",
description="Repeated tool-failure handling.",
)
experimental: Optional[ExperimentalConfig] = None

# Memory system configuration (added for memory system integration)
Expand Down Expand Up @@ -1372,14 +1395,23 @@ async def resolve_default_llm(cls) -> Optional[Dict[str, str]]:
return None

@classmethod
async def update(cls, config: ConfigInfo, project_dir: Optional[Path] = None) -> None:
async def update(
cls,
config: ConfigInfo,
project_dir: Optional[Path] = None,
*,
channel_allow_from_deletions: Optional[set[str]] = None,
) -> None:
"""
Update configuration

Args:
config: New configuration
project_dir: Deprecated and ignored. Config is always written to
the unified user config directory.
channel_allow_from_deletions: Channel IDs whose persisted
allowFrom field should be removed after a successful full
config validation and merge.
"""
_ = project_dir

Expand All @@ -1396,6 +1428,13 @@ async def update(cls, config: ConfigInfo, project_dir: Optional[Path] = None) ->

# Write
config_data = merged.model_dump(by_alias=True, exclude_none=True, mode="json")
if channel_allow_from_deletions:
channels = config_data.get("channels")
if isinstance(channels, dict):
for channel_id in channel_allow_from_deletions:
channel_cfg = channels.get(channel_id)
if isinstance(channel_cfg, dict):
channel_cfg.pop("allowFrom", None)
config_file.write_text(json.dumps(config_data, indent=2), encoding="utf-8")

# Clear cache
Expand Down
112 changes: 109 additions & 3 deletions flocks/server/routes/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import re
import xml.etree.ElementTree as ET
from pathlib import Path
from typing import Dict, Any, Optional
from typing import Dict, Any, Optional, Literal
from fastapi import APIRouter, File, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field
Expand All @@ -35,6 +35,36 @@
log = Log.create(service="routes.config")


def _channel_allow_from_deletion_ids(config_data: Dict[str, Any]) -> set[str]:
"""Return channel IDs whose PATCH explicitly removes allowFrom."""
channels = config_data.get("channels")
if not isinstance(channels, dict):
return set()

return {
channel_id
for channel_id, channel_cfg in channels.items()
if isinstance(channel_cfg, dict)
and "allowFrom" in channel_cfg
and channel_cfg.get("allowFrom") is None
}


def _normalize_slack_dm_policy(config_data: Dict[str, Any]) -> None:
"""Keep Slack allowFrom and dmPolicy aligned for DM access control."""
channels = config_data.get("channels")
if not isinstance(channels, dict):
return
slack = channels.get("slack")
if not isinstance(slack, dict) or "allowFrom" not in slack:
return
allow_from = slack.get("allowFrom")
if isinstance(allow_from, list) and len(allow_from) > 0:
slack["dmPolicy"] = "allowlist"
else:
slack["dmPolicy"] = "open"


def _build_model_from_config(
provider_id: str,
model_id: str,
Expand Down Expand Up @@ -131,6 +161,7 @@ class UIDisplayResponse(BaseModel):
display_name: str = Field(alias="displayName")
configured_display_name: Optional[str] = Field(None, alias="configuredDisplayName")
favicon_url: Optional[str] = Field(None, alias="faviconUrl")
theme: Optional[Literal["light", "dark"]] = Field(None)


class UIConfigUpdateRequest(BaseModel):
Expand All @@ -139,6 +170,18 @@ class UIConfigUpdateRequest(BaseModel):
model_config = {"populate_by_name": True}

display_name: Optional[str] = Field(None, alias="displayName")
theme: Optional[Literal["light", "dark"]] = Field(None)


class ToolFailurePreference(BaseModel):
"""Repeated tool-failure preference exposed to the WebUI."""

model_config = {"populate_by_name": True}

disable_on_repeated_failure: bool = Field(
...,
alias="disableOnRepeatedFailure",
)


DEFAULT_UI_DISPLAY_NAME = "Flocks"
Expand Down Expand Up @@ -400,16 +443,24 @@ def _persist_ui_section(data: Dict[str, Any], ui_section: Dict[str, Any]) -> Non
ConfigWriter._write_raw(data)


def _effective_tool_failure_preference(config: ConfigInfoModel) -> bool:
if config.tool_failure is None:
return True
return config.tool_failure.disable_on_repeated_failure


@router.get("/ui-display", response_model=UIDisplayResponse, summary="Get public UI display name")
async def get_ui_display() -> UIDisplayResponse:
"""Return only the effective WebUI display name for public screens."""
try:
complete_config = await Config.get()
display_name, configured_display_name = _effective_display_name(complete_config)
theme = complete_config.ui.theme if complete_config.ui else None
return UIDisplayResponse(
displayName=display_name,
configuredDisplayName=configured_display_name,
faviconUrl=_favicon_url(complete_config),
theme=theme,
)
except Exception as e:
log.error("config.ui_display.get.error", {"error": str(e)})
Expand All @@ -420,7 +471,10 @@ async def get_ui_display() -> UIDisplayResponse:
async def update_ui_config(request: UIConfigUpdateRequest) -> UIDisplayResponse:
"""Update visible WebUI display preferences."""
try:
ui_config = UIConfig.model_validate({"displayName": request.display_name})
ui_config = UIConfig.model_validate({
"displayName": request.display_name,
"theme": request.theme,
})
data = ConfigWriter._read_raw()
ui_section = _get_or_create_ui_section(data)

Expand All @@ -429,6 +483,11 @@ async def update_ui_config(request: UIConfigUpdateRequest) -> UIDisplayResponse:
else:
ui_section.pop("displayName", None)

if ui_config.theme is not None:
ui_section["theme"] = ui_config.theme
else:
ui_section.pop("theme", None)

_persist_ui_section(data, ui_section)
return await get_ui_display()
except Exception as e:
Expand Down Expand Up @@ -521,6 +580,47 @@ async def reset_ui_favicon() -> UIDisplayResponse:
return await get_ui_display()


@router.get(
"/tool-failure",
response_model=ToolFailurePreference,
summary="Get repeated tool-failure preference",
)
async def get_tool_failure_preference() -> ToolFailurePreference:
"""Return whether repeated identical failures automatically disable tools."""
try:
config = await Config.get()
return ToolFailurePreference(
disableOnRepeatedFailure=_effective_tool_failure_preference(config)
)
except Exception as e:
log.error("config.tool_failure.get.error", {"error": str(e)})
raise HTTPException(status_code=500, detail=str(e))


@router.patch(
"/tool-failure",
response_model=ToolFailurePreference,
summary="Update repeated tool-failure preference",
)
async def update_tool_failure_preference(
request: ToolFailurePreference,
) -> ToolFailurePreference:
"""Update only the repeated-failure switch in flocks.json."""
try:
data = ConfigWriter._read_raw()
existing = data.get("toolFailure", data.get("tool_failure", {}))
section = dict(existing) if isinstance(existing, dict) else {}
section.pop("disable_on_repeated_failure", None)
section["disableOnRepeatedFailure"] = request.disable_on_repeated_failure
data.pop("tool_failure", None)
data["toolFailure"] = section
ConfigWriter._write_raw(data)
return await get_tool_failure_preference()
except Exception as e:
log.error("config.tool_failure.update.error", {"error": str(e)})
raise HTTPException(status_code=400, detail=str(e))


@router.get("", summary="Get configuration")
async def get_config() -> Dict[str, Any]:
"""
Expand Down Expand Up @@ -554,6 +654,9 @@ async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]:
flocks.json, so that plaintext secrets never land in that file.
"""
try:
channel_allow_from_deletions = _channel_allow_from_deletion_ids(config_data)
_normalize_slack_dm_policy(config_data)

# Extract channel sensitive fields into .secret.json before persisting
if "channels" in config_data and isinstance(config_data.get("channels"), dict):
from flocks.security.channel_secrets import extract_channel_secrets
Expand All @@ -563,7 +666,10 @@ async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]:
config = ConfigInfoModel.model_validate(config_data)

# Update project config
await Config.update(config)
await Config.update(
config,
channel_allow_from_deletions=channel_allow_from_deletions,
)

# Clear cache to reload
Config.clear_cache()
Expand Down
1 change: 1 addition & 0 deletions webui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
document.documentElement.classList.add('dark');
document.documentElement.style.colorScheme = 'dark';
} else {
document.documentElement.classList.remove('dark');
document.documentElement.style.colorScheme = 'light';
}
} catch (error) {
Expand Down
2 changes: 2 additions & 0 deletions webui/src/api/uiConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@ export interface UIDisplayConfig {
displayName: string;
configuredDisplayName?: string | null;
faviconUrl?: string | null;
theme?: 'light' | 'dark' | null;
}

export interface UIConfigUpdate {
displayName?: string | null;
theme?: 'light' | 'dark' | null;
}

export const uiConfigApi = {
Expand Down
Loading