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
82 changes: 82 additions & 0 deletions flocks/config/config_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -525,6 +525,88 @@ def remove_api_service(cls, service_id: str) -> bool:
log.info("config_writer.api_service_removed", {"service_id": service_id})
return True

# ------------------------------------------------------------------
# Tool settings (tool_settings section)
# ------------------------------------------------------------------
#
# User-level overlay for per-tool settings (currently: ``enabled``).
# The section mirrors ``model_settings`` for naming consistency —
# both are flat maps keyed by the entity's unique id.
#
# Why this exists: YAML plugin tool files under
# ``<project>/.flocks/plugins/tools/`` are tracked by git and may be
# overwritten on upgrade. Writing a user toggle (e.g. enable/disable)
# back into the YAML pollutes git diffs and breaks upgrades. We keep
# the YAML as "factory defaults" and store the user's choice in
# ``flocks.json`` instead.
#
# The same overlay applies uniformly to user-level YAML files under
# ``~/.flocks/plugins/tools/`` so that UI behaviour is consistent
# regardless of where the YAML lives.

@classmethod
def list_tool_settings(cls) -> Dict[str, Dict[str, Any]]:
"""Return all raw tool_settings entries from flocks.json."""
data = cls._read_raw()
settings = data.get("tool_settings", {})
return settings if isinstance(settings, dict) else {}

@classmethod
def get_tool_setting(cls, tool_name: str) -> Optional[Dict[str, Any]]:
"""Read a single tool_settings entry, or None if not set."""
settings = cls.list_tool_settings()
entry = settings.get(tool_name)
return entry if isinstance(entry, dict) else None

@classmethod
def set_tool_setting(cls, tool_name: str, setting: Dict[str, Any]) -> None:
"""Merge ``setting`` into the tool_settings[tool_name] entry.

Existing keys not present in ``setting`` are preserved so callers
can update a single field (e.g. ``{"enabled": False}``) without
wiping other overlay fields that may be added later.
"""
if not tool_name:
raise ValueError("tool_name must be a non-empty string")
data = cls._read_raw()
settings = data.get("tool_settings")
if not isinstance(settings, dict):
settings = {}
existing = settings.get(tool_name)
if not isinstance(existing, dict):
existing = {}
merged = {**existing, **(setting or {})}
settings[tool_name] = merged
data["tool_settings"] = settings
cls._write_raw(data)
log.info("config_writer.tool_setting_set", {
"tool": tool_name,
"fields": sorted(merged.keys()),
})

@classmethod
def delete_tool_setting(cls, tool_name: str) -> bool:
"""Remove the tool_settings[tool_name] entry.

Pops the whole ``tool_settings`` key when the last entry is
removed so flocks.json doesn't accumulate empty container objects
as users toggle their last customised tool back to default.

Returns True if an entry existed and was removed.
"""
data = cls._read_raw()
settings = data.get("tool_settings")
if not isinstance(settings, dict) or tool_name not in settings:
return False
del settings[tool_name]
if settings:
data["tool_settings"] = settings
else:
data.pop("tool_settings", None)
cls._write_raw(data)
log.info("config_writer.tool_setting_removed", {"tool": tool_name})
return True

# ------------------------------------------------------------------
# Default model cleanup helpers
# ------------------------------------------------------------------
Expand Down
138 changes: 124 additions & 14 deletions flocks/server/routes/tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ class ToolInfoResponse(BaseModel):
source: str = Field("builtin", description="Tool source: builtin, mcp, api, custom")
source_name: Optional[str] = Field(None, description="Source detail, e.g. MCP server name or API module name")
parameters: List[Dict[str, Any]] = Field(default_factory=list, description="Tool parameters")
enabled: bool = Field(True, description="Is tool enabled")
enabled: bool = Field(True, description="Effective enabled state (overlay applied, ANDed with API service flag)")
enabled_default: bool = Field(True, description="Factory default from the YAML/registration source (no overlay)")
enabled_customized: bool = Field(False, description="True if a user setting is recorded in flocks.json tool_settings")
requires_confirmation: bool = Field(False, description="Requires confirmation")


Expand Down Expand Up @@ -126,8 +128,11 @@ def _get_tool_source(tool_info: ToolInfo) -> tuple:


def _build_tool_response(t: ToolInfo) -> ToolInfoResponse:
"""Build ToolInfoResponse with source info."""
"""Build ToolInfoResponse with source info and overlay metadata."""
source, source_name = _get_tool_source(t)
setting = ConfigWriter.get_tool_setting(t.name) or {}
customized = "enabled" in setting
enabled_default = _get_default_enabled(t)
return ToolInfoResponse(
name=t.name,
description=t.description,
Expand All @@ -137,10 +142,47 @@ def _build_tool_response(t: ToolInfo) -> ToolInfoResponse:
source_name=source_name,
parameters=[p.model_dump() for p in t.parameters],
enabled=_get_effective_tool_enabled(t),
enabled_default=enabled_default,
enabled_customized=customized,
requires_confirmation=t.requires_confirmation,
)


def _get_default_enabled(t: ToolInfo) -> bool:
"""Return the registration-time default for ``enabled``.

Prefers :meth:`ToolRegistry.get_default_enabled` (a snapshot taken
before sync/overlay mutate ``info.enabled`` in place). Falls back to
the YAML file when the snapshot is missing (e.g. a tool registered
after init), then to the live value as the very last resort.
"""
snapshot = ToolRegistry.get_default_enabled(t.name)
if snapshot is not None:
return snapshot
try:
from flocks.tool.tool_loader import read_yaml_tool
raw = read_yaml_tool(t.name)
except Exception:
raw = None
if isinstance(raw, dict) and "enabled" in raw:
return bool(raw["enabled"])
return t.enabled


def _service_allows_enable(t: ToolInfo) -> bool:
"""Return True when the API service backing ``t`` (if any) is enabled.

Mirrors the gate in :meth:`ToolRegistry._apply_tool_settings` so that
HTTP mutations stay consistent with what the registry would compute
on its next reload: an overlay can never *open* a tool whose service
is currently disabled.
"""
if not t.provider:
return True
svc = ConfigWriter.get_api_service_raw(t.provider) or {}
return bool(svc.get("enabled", False))


def _get_effective_tool_enabled(tool_info: ToolInfo) -> bool:
"""Compute tool enabled state without mutating the registry object."""
source, source_name = _get_tool_source(tool_info)
Expand Down Expand Up @@ -230,17 +272,79 @@ async def get_tool(tool_name: str):
)
async def update_tool(tool_name: str, request: ToolUpdateRequest):
"""
Update tool settings (e.g., enable or disable)
Update tool settings (e.g., enable or disable).

The ``enabled`` flag is persisted to the user-level overlay in
``flocks.json`` (``tool_settings.<tool_name>.enabled``) instead of
mutating the YAML plugin file. This keeps project-level YAML files
(which may be tracked by git and overwritten on upgrade) clean and
treats the YAML's ``enabled:`` field as the factory default that the
overlay can selectively customise.

Two behaviours of note:

* If ``request.enabled`` matches the registration-time default we
*delete* the overlay entry instead of writing one — the tool is
back to "no customisation", and the UI's "已自定义" badge clears.
* Asking to enable a tool whose API service is currently disabled
still persists the overlay (so the intent survives the service
being re-enabled later) but does not flip the in-memory
``info.enabled`` flag, mirroring the gate in
:meth:`ToolRegistry._apply_tool_settings`.
"""
ToolRegistry.init()

Args:
tool_name: Tool name
request: Update payload
tool = ToolRegistry.get(tool_name)
if not tool:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Tool not found: {tool_name}",
)

Returns:
Updated tool information
"""
from flocks.tool.tool_loader import find_yaml_tool, update_yaml_tool
desired = bool(request.enabled)
default = _get_default_enabled(tool.info)
# Service gate: only matters when the user is trying to enable.
# Disabling is always honoured.
service_ok = _service_allows_enable(tool.info)
new_enabled = desired and service_ok

if desired == default:
removed = ConfigWriter.delete_tool_setting(tool_name)
log.info("tool.updated.reset_to_default", {
"name": tool_name,
"enabled": new_enabled,
"default": default,
"removed_overlay": removed,
})
else:
ConfigWriter.set_tool_setting(tool_name, {"enabled": desired})
log.info("tool.updated", {
"name": tool_name,
"enabled": new_enabled,
"requested": desired,
"blocked_by_service": desired and not service_ok,
"native": tool.info.native,
"store": "overlay",
})

tool.info.enabled = new_enabled
return _build_tool_response(tool.info)


@router.post(
"/{tool_name}/reset",
response_model=ToolInfoResponse,
summary="Reset a tool to its YAML/registration default",
)
async def reset_tool_setting(tool_name: str):
"""Remove the user setting for ``tool_name`` and restore the default.

Restores the registration-time ``enabled`` value from the registry's
snapshot (or the YAML file as a fallback) and re-applies the same
service gate as :meth:`ToolRegistry._apply_tool_settings`, so the
HTTP layer never leaves the in-memory state in a position the
registry would refuse on its next reload.
"""
ToolRegistry.init()

tool = ToolRegistry.get(tool_name)
Expand All @@ -250,11 +354,17 @@ async def update_tool(tool_name: str, request: ToolUpdateRequest):
detail=f"Tool not found: {tool_name}",
)

if find_yaml_tool(tool_name):
update_yaml_tool(tool_name, {"enabled": request.enabled})
removed = ConfigWriter.delete_tool_setting(tool_name)
default = _get_default_enabled(tool.info)
new_enabled = default and _service_allows_enable(tool.info)
tool.info.enabled = new_enabled

tool.info.enabled = request.enabled
log.info("tool.updated", {"name": tool_name, "enabled": request.enabled})
log.info("tool.setting.reset", {
"name": tool_name,
"removed": removed,
"default": default,
"restored_enabled": new_enabled,
})
return _build_tool_response(tool.info)


Expand Down
Loading
Loading