RFC-017: Tool Management
RFC-017: Tool Management
Document Information
- RFC ID: RFC-017
- Related PRD: FEAT-017 (Tool Management)
- Dependencies: RFC-004 (Tool System), RFC-009 (Settings), RFC-012 (JS Tool Engine), RFC-014 (Agent Skill), RFC-015 (JS Tool Migration), RFC-018 (JS Tool Group)
- Created: 2026-02-28
- Last Updated: 2026-02-28
- Status: Draft
- Author: TBD
Overview
Background
OneClaw currently has no centralized screen to view, inspect, or control tools. Tools are only visible through per-agent configuration (FEAT-002), and there is no way to globally disable a tool or inspect its full definition (parameters, permissions, timeout). As the tool ecosystem grows – with built-in Kotlin tools, built-in JS tools (RFC-015), user JS extensions (RFC-012), and tool groups (RFC-018) – a management UI becomes essential.
Goals
- Add a “Manage Tools” entry in Settings that opens a Tool Management screen
- Display all registered tools organized in three sections: Built-in (flat), Tool Groups (collapsible), Standalone JS (flat)
- Provide a global enable/disable toggle per tool, persisted in SharedPreferences
- Provide group-level enable/disable with parent-child toggle relationship
- Show a Tool Detail view with full tool metadata (name, description, parameters, permissions, timeout, source, group)
- Integrate the global disable check into
ToolExecutionEngineto block disabled tools - Show globally disabled tools as grayed-out and non-selectable in Agent configuration (FEAT-002)
Non-Goals
- Tool creation, editing, or deletion from this screen (JS tools managed via file system per FEAT-012; skills via FEAT-014)
- Tool execution history or logs
- Tool testing or dry-run capability
- Tool search/filter (deferred)
- Notifications when a tool is disabled while in active conversation
- Bulk import/export of tool enable/disable configuration
Technical Design
Architecture Overview
┌─────────────────────────────────────────────────────────────────────┐
│ UI Layer │
│ │
│ SettingsScreen ToolManagementScreen AgentDetailScreen │
│ (new "Manage Tools" (list + detail) (grayed-out tools) │
│ entry) │ │ │
│ ToolManagementViewModel AgentDetailVM │
│ │ │ │
└────────────────────────────────┼───────────────────────┼───────────┘
│ │
┌────────────────────────────────┼───────────────────────┼───────────┐
│ Domain / Data Layer │ │
│ │ │ │
│ ToolEnabledStateStore ────────┤ │ │
│ (SharedPreferences) │ │ │
│ │ │ │
│ ToolRegistry ─────────────────┤───────────────────────┘ │
│ (+ ToolSourceInfo tracking) │ │
│ │ │
│ ToolExecutionEngine ──────────┘ │
│ (new global enable check) │
└─────────────────────────────────────────────────────────────────────┘
Files modified: 7
ToolRegistry.kt– add source info tracking and group query methodsToolExecutionEngine.kt– add global enable checkToolModule.kt– register tools with source info, wire new dependenciesSettingsScreen.kt– add “Manage Tools” entryAgentDetailViewModel.kt– add globally disabled flag to tool itemsAgentDetailScreen.kt– show visual indicator for globally disabled toolsAgentUiState.kt– addisGloballyDisabledfield toToolOptionItem
Files added: 5
ToolEnabledStateStore.kt– SharedPreferences store for enable/disable stateToolSourceInfo.kt– data model for tool origin metadataToolManagementViewModel.kt– ViewModel for tool management screenToolManagementScreen.kt– Composable for tool list and detail viewsRoute.ktupdate (or new route entry) – navigation route for tool management
Core Components
1. ToolSourceInfo (Domain Model)
Tracks the origin and group membership of each tool. This is metadata about how a tool was loaded, stored alongside the tool in the registry.
package com.oneclaw.shadow.core.model
/**
* Source type classification for tools in the management UI.
*/
enum class ToolSourceType {
/** Kotlin-implemented tool or JS tool loaded from app assets. */
BUILTIN,
/** JS tool loaded from an array manifest file (RFC-018). */
TOOL_GROUP,
/** Single-file JS extension tool from device storage, not part of any group. */
JS_EXTENSION
}
/**
* Metadata describing the origin of a registered tool.
*/
data class ToolSourceInfo(
val type: ToolSourceType,
val groupName: String? = null,
val filePath: String? = null
)
Location: app/src/main/kotlin/com/oneclaw/shadow/core/model/ToolSourceInfo.kt
Classification rules:
LoadSkillTool(Kotlin):ToolSourceType.BUILTIN, groupName=null- Tools loaded from
assets/js/tools/(built-in JS):ToolSourceType.BUILTIN, groupName=null - Tools loaded from array manifest on device storage:
ToolSourceType.TOOL_GROUP, groupName derived from manifest filename - Single-file JS tools from device storage:
ToolSourceType.JS_EXTENSION, groupName=null
Group name derivation: Manifest filename without extension, underscores replaced by spaces, each word capitalized. For example, google_drive.json becomes “Google Drive”.
2. ToolRegistry Enhancements
Add source info tracking and group query methods to the existing ToolRegistry.
class ToolRegistry {
private val tools = mutableMapOf<String, Tool>()
private val sourceInfoMap = mutableMapOf<String, ToolSourceInfo>()
/**
* Register a tool with optional source metadata.
* If sourceInfo is null, defaults to BUILTIN.
*/
fun register(tool: Tool, sourceInfo: ToolSourceInfo? = null) {
val name = tool.definition.name
require(!tools.containsKey(name)) {
"Tool with name '$name' is already registered"
}
tools[name] = tool
sourceInfoMap[name] = sourceInfo ?: ToolSourceInfo(type = ToolSourceType.BUILTIN)
}
/** Get the source info for a tool. */
fun getToolSourceInfo(name: String): ToolSourceInfo? = sourceInfoMap[name]
/** Get all source info entries. */
fun getAllToolSourceInfo(): Map<String, ToolSourceInfo> = sourceInfoMap.toMap()
/**
* Get tool names grouped by their group name.
* Only returns tools with ToolSourceType.TOOL_GROUP.
*/
fun getToolGroups(): Map<String, List<String>> {
return sourceInfoMap.entries
.filter { it.value.type == ToolSourceType.TOOL_GROUP && it.value.groupName != null }
.groupBy({ it.value.groupName!! }, { it.key })
}
// ... existing methods unchanged: getTool, getAllToolDefinitions,
// getToolDefinitionsByNames, hasTool, getAllToolNames, unregister, etc.
}
Backward compatibility: The existing register(tool: Tool) single-parameter call remains supported. The sourceInfo parameter defaults to null, which maps to ToolSourceType.BUILTIN. All existing code continues to work without modification.
Location: app/src/main/kotlin/com/oneclaw/shadow/tool/engine/ToolRegistry.kt
3. ToolEnabledStateStore
Persists the global enable/disable state for individual tools and tool groups in SharedPreferences.
package com.oneclaw.shadow.tool.engine
import android.content.Context
import android.content.SharedPreferences
class ToolEnabledStateStore(context: Context) {
private val prefs: SharedPreferences = context.getSharedPreferences(
"tool_enabled_state", Context.MODE_PRIVATE
)
/**
* Check if a tool is globally enabled.
* Default: true (all tools enabled unless explicitly disabled).
*/
fun isToolEnabled(toolName: String): Boolean =
prefs.getBoolean("tool_enabled_$toolName", true)
/**
* Set the global enabled state for a tool.
*/
fun setToolEnabled(toolName: String, enabled: Boolean) {
prefs.edit().putBoolean("tool_enabled_$toolName", enabled).apply()
}
/**
* Check if a tool group is globally enabled.
* Default: true (all groups enabled unless explicitly disabled).
*/
fun isGroupEnabled(groupName: String): Boolean =
prefs.getBoolean("tool_group_enabled_$groupName", true)
/**
* Set the global enabled state for a tool group.
*/
fun setGroupEnabled(groupName: String, enabled: Boolean) {
prefs.edit().putBoolean("tool_group_enabled_$groupName", enabled).apply()
}
/**
* Check if a tool is effectively enabled, considering both
* individual and group state.
*
* A tool is effectively enabled only if:
* 1. Its individual toggle is ON, AND
* 2. If it belongs to a group, the group toggle is also ON.
*/
fun isToolEffectivelyEnabled(toolName: String, groupName: String?): Boolean {
if (!isToolEnabled(toolName)) return false
if (groupName != null && !isGroupEnabled(groupName)) return false
return true
}
}
Location: app/src/main/kotlin/com/oneclaw/shadow/tool/engine/ToolEnabledStateStore.kt
Design decisions:
- Uses plain
SharedPreferences(notEncryptedSharedPreferences) since enable/disable state is not sensitive data. - Default is
true(enabled) for both tools and groups, ensuring backward compatibility. - The
isToolEffectivelyEnabledmethod encapsulates the two-level check (individual + group) so callers don’t need to implement this logic.
4. ToolExecutionEngine Integration
Add a global enable check to the existing tool execution pipeline.
class ToolExecutionEngine(
private val toolRegistry: ToolRegistry,
private val permissionChecker: PermissionChecker,
private val enabledStateStore: ToolEnabledStateStore // NEW dependency
) {
suspend fun executeTool(
toolName: String,
parameters: Map<String, Any?>,
agentToolIds: List<String>
): ToolResult {
// Step 1: Look up tool in registry
val tool = toolRegistry.getTool(toolName)
?: return ToolResult.error("tool_not_found", "Tool '$toolName' not found")
// Step 2: Check availability (per-agent)
if (agentToolIds.isNotEmpty() && toolName !in agentToolIds) {
return ToolResult.error(
"tool_not_available",
"Tool '$toolName' is not available for this agent"
)
}
// Step 3: NEW -- Check global enable state
val sourceInfo = toolRegistry.getToolSourceInfo(toolName)
if (!enabledStateStore.isToolEffectivelyEnabled(toolName, sourceInfo?.groupName)) {
return ToolResult.error(
"tool_globally_disabled",
"Tool '$toolName' is globally disabled and not available."
)
}
// Step 4: Validate parameters (existing)
// Step 5: Check permissions (existing)
// Step 6: Execute with timeout (existing)
// ... rest unchanged
}
}
Error code: tool_globally_disabled – a new distinct error type so the model can recognize the tool is disabled (not missing or unauthorized).
Location: app/src/main/kotlin/com/oneclaw/shadow/tool/engine/ToolExecutionEngine.kt
5. ToolManagementViewModel
Manages the state for the Tool Management screen.
package com.oneclaw.shadow.feature.tool
import androidx.lifecycle.ViewModel
import com.oneclaw.shadow.core.model.ToolDefinition
import com.oneclaw.shadow.core.model.ToolSourceInfo
import com.oneclaw.shadow.core.model.ToolSourceType
import com.oneclaw.shadow.tool.engine.ToolEnabledStateStore
import com.oneclaw.shadow.tool.engine.ToolRegistry
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
data class ToolManagementUiState(
val builtInTools: List<ToolUiItem> = emptyList(),
val toolGroups: List<ToolGroupUiItem> = emptyList(),
val standaloneTools: List<ToolUiItem> = emptyList(),
val selectedTool: ToolDetailUiItem? = null,
val snackbarMessage: String? = null
)
data class ToolUiItem(
val name: String,
val description: String,
val sourceType: ToolSourceType,
val isEnabled: Boolean,
val groupName: String? = null
)
data class ToolGroupUiItem(
val groupName: String,
val tools: List<ToolUiItem>,
val isGroupEnabled: Boolean,
val isExpanded: Boolean = false
)
data class ToolDetailUiItem(
val name: String,
val description: String,
val parametersSchema: ToolParametersSchema,
val requiredPermissions: List<String>,
val timeoutSeconds: Int,
val sourceType: ToolSourceType,
val groupName: String?,
val filePath: String?,
val isEnabled: Boolean
)
class ToolManagementViewModel(
private val toolRegistry: ToolRegistry,
private val enabledStateStore: ToolEnabledStateStore
) : ViewModel() {
private val _uiState = MutableStateFlow(ToolManagementUiState())
val uiState: StateFlow<ToolManagementUiState> = _uiState.asStateFlow()
init {
loadTools()
}
fun loadTools() {
val allDefs = toolRegistry.getAllToolDefinitions()
val allSourceInfo = toolRegistry.getAllToolSourceInfo()
val groups = toolRegistry.getToolGroups()
val builtIn = mutableListOf<ToolUiItem>()
val standalone = mutableListOf<ToolUiItem>()
allDefs.forEach { def ->
val source = allSourceInfo[def.name]
?: ToolSourceInfo(type = ToolSourceType.BUILTIN)
when (source.type) {
ToolSourceType.BUILTIN -> builtIn.add(def.toUiItem(source))
ToolSourceType.JS_EXTENSION -> standalone.add(def.toUiItem(source))
ToolSourceType.TOOL_GROUP -> { /* handled in group section */ }
}
}
val toolGroupItems = groups.map { (groupName, toolNames) ->
val groupTools = toolNames.mapNotNull { name ->
val def = allDefs.find { it.name == name } ?: return@mapNotNull null
val source = allSourceInfo[name]
?: ToolSourceInfo(ToolSourceType.TOOL_GROUP, groupName)
def.toUiItem(source)
}.sortedBy { it.name }
ToolGroupUiItem(
groupName = groupName,
tools = groupTools,
isGroupEnabled = enabledStateStore.isGroupEnabled(groupName)
)
}.sortedBy { it.groupName }
_uiState.update {
it.copy(
builtInTools = builtIn.sortedBy { t -> t.name },
toolGroups = toolGroupItems,
standaloneTools = standalone.sortedBy { t -> t.name }
)
}
}
fun toggleToolEnabled(toolName: String) {
val currentState = enabledStateStore.isToolEnabled(toolName)
val newState = !currentState
enabledStateStore.setToolEnabled(toolName, newState)
val label = if (newState) "enabled" else "disabled"
// Check if all tools in a group are now disabled -> auto-disable group
val sourceInfo = toolRegistry.getToolSourceInfo(toolName)
if (sourceInfo?.type == ToolSourceType.TOOL_GROUP && sourceInfo.groupName != null) {
val groupTools = toolRegistry.getToolGroups()[sourceInfo.groupName] ?: emptyList()
val allDisabled = groupTools.all { !enabledStateStore.isToolEnabled(it) }
if (allDisabled) {
enabledStateStore.setGroupEnabled(sourceInfo.groupName, false)
}
}
loadTools()
_uiState.update { it.copy(snackbarMessage = "$toolName $label") }
}
fun toggleGroupEnabled(groupName: String) {
val currentState = enabledStateStore.isGroupEnabled(groupName)
val newState = !currentState
enabledStateStore.setGroupEnabled(groupName, newState)
val label = if (newState) "enabled" else "disabled"
loadTools()
_uiState.update { it.copy(snackbarMessage = "$groupName group $label") }
}
fun toggleGroupExpanded(groupName: String) {
_uiState.update { state ->
state.copy(
toolGroups = state.toolGroups.map { group ->
if (group.groupName == groupName) {
group.copy(isExpanded = !group.isExpanded)
} else group
}
)
}
}
fun selectTool(toolName: String) {
val def = toolRegistry.getAllToolDefinitions().find { it.name == toolName }
?: return
val source = toolRegistry.getToolSourceInfo(toolName)
?: ToolSourceInfo(ToolSourceType.BUILTIN)
_uiState.update {
it.copy(
selectedTool = ToolDetailUiItem(
name = def.name,
description = def.description,
parametersSchema = def.parametersSchema,
requiredPermissions = def.requiredPermissions,
timeoutSeconds = def.timeoutSeconds,
sourceType = source.type,
groupName = source.groupName,
filePath = source.filePath,
isEnabled = enabledStateStore.isToolEffectivelyEnabled(
def.name, source.groupName
)
)
)
}
}
fun clearSelectedTool() {
_uiState.update { it.copy(selectedTool = null) }
}
fun clearSnackbar() {
_uiState.update { it.copy(snackbarMessage = null) }
}
private fun ToolDefinition.toUiItem(source: ToolSourceInfo): ToolUiItem {
return ToolUiItem(
name = this.name,
description = this.description,
sourceType = source.type,
isEnabled = enabledStateStore.isToolEffectivelyEnabled(
this.name, source.groupName
),
groupName = source.groupName
)
}
}
Location: app/src/main/kotlin/com/oneclaw/shadow/feature/tool/ToolManagementViewModel.kt
6. ToolManagementScreen (UI)
The Tool Management screen has two views: a tool list and a tool detail view, managed as screen state within the ViewModel (not separate navigation destinations).
package com.oneclaw.shadow.feature.tool
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.oneclaw.shadow.core.model.ToolSourceType
import org.koin.androidx.compose.koinViewModel
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ToolManagementScreen(
onNavigateBack: () -> Unit,
viewModel: ToolManagementViewModel = koinViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
val snackbarHostState = remember { SnackbarHostState() }
// Handle snackbar messages
uiState.snackbarMessage?.let { message ->
LaunchedEffect(message) {
snackbarHostState.showSnackbar(message, duration = SnackbarDuration.Short)
viewModel.clearSnackbar()
}
}
// If a tool is selected, show detail view
if (uiState.selectedTool != null) {
ToolDetailView(
tool = uiState.selectedTool!!,
onBack = { viewModel.clearSelectedTool() },
onToggleEnabled = { viewModel.toggleToolEnabled(it) }
)
return
}
Scaffold(
topBar = {
TopAppBar(
title = { Text("Manage Tools") },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(bottom = 16.dp)
) {
// Built-in section
if (uiState.builtInTools.isNotEmpty()) {
item {
SectionHeader("BUILT-IN")
}
items(uiState.builtInTools, key = { it.name }) { tool ->
ToolListItem(
tool = tool,
onToggle = { viewModel.toggleToolEnabled(tool.name) },
onClick = { viewModel.selectTool(tool.name) }
)
}
}
// Tool Groups section
if (uiState.toolGroups.isNotEmpty()) {
item {
SectionHeader("TOOL GROUPS")
}
uiState.toolGroups.forEach { group ->
item(key = "group_${group.groupName}") {
ToolGroupHeader(
group = group,
onToggleGroup = {
viewModel.toggleGroupEnabled(group.groupName)
},
onToggleExpand = {
viewModel.toggleGroupExpanded(group.groupName)
}
)
}
if (group.isExpanded) {
items(
group.tools,
key = { "group_tool_${it.name}" }
) { tool ->
val isInteractive = group.isGroupEnabled
ToolListItem(
tool = tool,
onToggle = {
if (isInteractive) {
viewModel.toggleToolEnabled(tool.name)
}
},
onClick = { viewModel.selectTool(tool.name) },
isGroupChild = true,
isGroupDisabled = !group.isGroupEnabled
)
}
}
}
}
// Standalone section
if (uiState.standaloneTools.isNotEmpty()) {
item {
SectionHeader("STANDALONE")
}
items(uiState.standaloneTools, key = { it.name }) { tool ->
ToolListItem(
tool = tool,
onToggle = { viewModel.toggleToolEnabled(tool.name) },
onClick = { viewModel.selectTool(tool.name) }
)
}
}
// Empty state
if (uiState.builtInTools.isEmpty() &&
uiState.toolGroups.isEmpty() &&
uiState.standaloneTools.isEmpty()
) {
item {
Box(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Text(
text = "No tools available.",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
}
@Composable
private fun SectionHeader(title: String) {
Text(
text = title,
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp)
)
}
@Composable
private fun ToolListItem(
tool: ToolUiItem,
onToggle: () -> Unit,
onClick: () -> Unit,
isGroupChild: Boolean = false,
isGroupDisabled: Boolean = false
) {
val itemAlpha = if (isGroupDisabled) 0.38f else 1f
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick)
.alpha(itemAlpha)
.padding(
start = if (isGroupChild) 32.dp else 16.dp,
end = 16.dp,
top = 8.dp,
bottom = 8.dp
),
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text = tool.name,
style = MaterialTheme.typography.bodyLarge,
fontFamily = FontFamily.Monospace
)
Spacer(modifier = Modifier.width(8.dp))
SourceBadge(tool.sourceType)
}
Text(
text = tool.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1
)
}
Switch(
checked = tool.isEnabled,
onCheckedChange = { onToggle() },
enabled = !isGroupDisabled
)
}
}
@Composable
private fun SourceBadge(sourceType: ToolSourceType) {
val label = when (sourceType) {
ToolSourceType.BUILTIN -> "Built-in"
ToolSourceType.TOOL_GROUP -> "Tool Group"
ToolSourceType.JS_EXTENSION -> "JS Extension"
}
Surface(
shape = MaterialTheme.shapes.extraSmall,
color = MaterialTheme.colorScheme.secondaryContainer,
modifier = Modifier.padding(horizontal = 4.dp)
) {
Text(
text = label,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
)
}
}
@Composable
private fun ToolGroupHeader(
group: ToolGroupUiItem,
onToggleGroup: () -> Unit,
onToggleExpand: () -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onToggleExpand)
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = if (group.isExpanded) Icons.Default.ExpandLess
else Icons.Default.ExpandMore,
contentDescription = if (group.isExpanded) "Collapse" else "Expand",
modifier = Modifier.size(24.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = group.groupName,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f)
)
Text(
text = "${group.tools.size} tools",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(end = 8.dp)
)
Switch(
checked = group.isGroupEnabled,
onCheckedChange = { onToggleGroup() }
)
}
}
Location: app/src/main/kotlin/com/oneclaw/shadow/feature/tool/ToolManagementScreen.kt
7. ToolDetailView
A separate composable for the tool detail screen, shown when a tool is tapped.
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ToolDetailView(
tool: ToolDetailUiItem,
onBack: () -> Unit,
onToggleEnabled: (String) -> Unit
) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("Tool Details") },
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
}
)
}
) { padding ->
LazyColumn(
modifier = Modifier
.fillMaxSize()
.padding(padding),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
// Header
item {
Text(text = tool.name, style = MaterialTheme.typography.headlineSmall,
fontFamily = FontFamily.Monospace)
Spacer(modifier = Modifier.height(4.dp))
Text(text = tool.description, style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
// Metadata
item {
Spacer(modifier = Modifier.height(8.dp))
DetailRow("Source", tool.sourceType.displayLabel())
DetailRow("Group", tool.groupName ?: "None")
DetailRow("Timeout", "${tool.timeoutSeconds} seconds")
DetailRow("Permissions",
tool.requiredPermissions.ifEmpty { listOf("None") }.joinToString(", "))
if (tool.filePath != null) {
DetailRow("File", tool.filePath)
}
}
// Enable toggle
item {
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Text("Enabled", style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.weight(1f))
Switch(
checked = tool.isEnabled,
onCheckedChange = { onToggleEnabled(tool.name) }
)
}
}
// Parameters section
item {
HorizontalDivider()
Spacer(modifier = Modifier.height(8.dp))
Text("PARAMETERS", style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.primary)
}
if (tool.parametersSchema.properties.isEmpty()) {
item {
Text("No parameters",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
items(tool.parametersSchema.properties.entries.toList()) { (name, param) ->
val isRequired = name in tool.parametersSchema.required
ParameterItem(name, param, isRequired)
}
}
}
}
}
@Composable
private fun DetailRow(label: String, value: String) {
Row(modifier = Modifier.padding(vertical = 2.dp)) {
Text(
text = "$label: ",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = value,
style = MaterialTheme.typography.bodyMedium
)
}
}
@Composable
private fun ParameterItem(
name: String,
param: ToolParameter,
isRequired: Boolean
) {
Column(modifier = Modifier.padding(vertical = 4.dp)) {
Row {
Text(
text = name,
style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace
)
Text(
text = " (${param.type})",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = if (isRequired) " *required" else " optional",
style = MaterialTheme.typography.labelSmall,
color = if (isRequired) MaterialTheme.colorScheme.error
else MaterialTheme.colorScheme.onSurfaceVariant
)
}
Text(
text = param.description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
if (param.enum != null) {
Text(
text = "Values: ${param.enum.joinToString(", ")}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
if (param.default != null) {
Text(
text = "Default: ${param.default}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
private fun ToolSourceType.displayLabel(): String = when (this) {
ToolSourceType.BUILTIN -> "Built-in"
ToolSourceType.TOOL_GROUP -> "Tool Group"
ToolSourceType.JS_EXTENSION -> "JS Extension"
}
Location: Same file as ToolManagementScreen.kt (or split into ToolDetailView.kt if preferred).
8. SettingsScreen Integration
Add onManageTools parameter and a new “Tools” section.
@Composable
fun SettingsScreen(
onNavigateBack: () -> Unit,
onManageProviders: () -> Unit,
onManageAgents: () -> Unit = {},
onManageTools: () -> Unit = {}, // NEW
onUsageStatistics: () -> Unit = {},
onDataBackup: () -> Unit = {},
onMemory: () -> Unit = {},
onSkills: () -> Unit = {},
themeManager: ThemeManager = koinInject()
) {
// ... existing code ...
// After "Agents" section, before "Usage" section:
SectionHeader("Tools")
SettingsItem(
title = "Manage Tools",
subtitle = "View and enable/disable tools",
onClick = onManageTools
)
HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp))
// ... existing Usage, Memory, Skills, Data & Backup sections ...
}
9. AgentDetailViewModel Integration
Modify loadAvailableTools() to include global disable state.
// In AgentDetailViewModel.kt
fun loadAvailableTools() {
val tools = toolRegistry.getAllToolDefinitions().map { toolDef ->
val sourceInfo = toolRegistry.getToolSourceInfo(toolDef.name)
val isGloballyDisabled = !enabledStateStore.isToolEffectivelyEnabled(
toolDef.name, sourceInfo?.groupName
)
ToolOptionItem(
name = toolDef.name,
description = toolDef.description,
isSelected = toolDef.name in selectedToolIds,
isGloballyDisabled = isGloballyDisabled
)
}
_uiState.update { it.copy(availableTools = tools) }
}
ToolOptionItem enhancement (in AgentUiState.kt):
data class ToolOptionItem(
val name: String,
val description: String,
val isSelected: Boolean,
val isGloballyDisabled: Boolean = false // NEW
)
10. AgentDetailScreen Integration
Show visual indicators for globally disabled tools.
// In AgentDetailScreen.kt, tool list section:
items(uiState.availableTools, key = { it.name }) { tool ->
Row(
modifier = Modifier
.fillMaxWidth()
.alpha(if (tool.isGloballyDisabled) 0.38f else 1f)
.padding(horizontal = 16.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = tool.name in uiState.selectedToolIds,
onCheckedChange = {
if (!tool.isGloballyDisabled) viewModel.toggleTool(tool.name)
},
enabled = !uiState.isBuiltIn && !tool.isGloballyDisabled
)
Column(modifier = Modifier.weight(1f)) {
Text(text = tool.name, style = MaterialTheme.typography.bodyMedium,
fontFamily = FontFamily.Monospace)
Text(text = tool.description, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant)
if (tool.isGloballyDisabled) {
Text(
text = "Globally disabled",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.error
)
}
}
}
}
11. Navigation Route
Add a route for the Tool Management screen.
// In Route.kt (sealed class)
data object ManageTools : Route
// In NavHost setup:
composable<Route.ManageTools> {
ToolManagementScreen(
onNavigateBack = { navController.popBackStack() }
)
}
// In Settings navigation:
SettingsScreen(
onManageTools = { navController.navigate(Route.ManageTools) },
// ... existing params
)
12. Koin DI Module
Register new components in the appropriate modules.
// In ToolModule.kt:
single { ToolEnabledStateStore(androidContext()) }
// ToolExecutionEngine now takes ToolEnabledStateStore:
single { ToolExecutionEngine(get(), get(), get()) }
// In FeatureModule.kt:
viewModel { ToolManagementViewModel(get(), get()) }
// AgentDetailViewModel now takes ToolEnabledStateStore:
viewModel { AgentDetailViewModel(get(), get(), get(), get()) }
ToolModule Registration with Source Info
Update tool registration calls in ToolModule.kt to include source info:
val toolModule = module {
// ... existing JS engine, library bridge, etc. ...
single {
ToolRegistry().apply {
// LoadSkillTool: Kotlin built-in
register(
LoadSkillTool(get()),
ToolSourceInfo(type = ToolSourceType.BUILTIN)
)
// Built-in JS tools from assets
val builtInTools = jsToolLoader.loadBuiltInTools()
builtInTools.forEach { tool ->
register(
tool,
ToolSourceInfo(
type = ToolSourceType.BUILTIN,
filePath = (tool as? JsTool)?.jsFilePath
)
)
}
// User JS tools from device storage
val userToolResults = jsToolLoader.loadUserTools()
userToolResults.forEach { result ->
when (result) {
is JsToolLoadResult.SingleTool -> {
register(
result.tool,
ToolSourceInfo(
type = ToolSourceType.JS_EXTENSION,
filePath = result.tool.jsFilePath
)
)
}
is JsToolLoadResult.ToolGroup -> {
result.tools.forEach { tool ->
register(
tool,
ToolSourceInfo(
type = ToolSourceType.TOOL_GROUP,
groupName = result.groupName,
filePath = tool.jsFilePath
)
)
}
}
}
}
}
}
single { ToolEnabledStateStore(androidContext()) }
single { ToolExecutionEngine(get(), get(), get()) }
}
Note: This assumes JsToolLoader returns structured results distinguishing single tools from groups. The exact API depends on RFC-018 implementation, but the concept is: during loading, we know whether a tool came from a single-file manifest or an array manifest, and we pass this info as ToolSourceInfo.
Data Flow
Global Disable Flow
1. User opens Settings → taps "Manage Tools"
2. ToolManagementScreen renders, ToolManagementViewModel loads tools from ToolRegistry + state from ToolEnabledStateStore
3. User taps toggle for "http_request" → toggleToolEnabled("http_request")
4. ViewModel calls enabledStateStore.setToolEnabled("http_request", false)
5. SharedPreferences persists: tool_enabled_http_request = false
6. ViewModel refreshes UI state → toggle shows OFF, snackbar "http_request disabled"
7. Later, AI model requests tool call: http_request(url="...")
8. ToolExecutionEngine.executeTool("http_request", ...) checks:
- Tool exists? Yes
- Agent has tool? Yes
- Tool globally enabled? → enabledStateStore.isToolEffectivelyEnabled("http_request", null) → false
- Returns ToolResult.error("tool_globally_disabled", "Tool 'http_request' is globally disabled and not available.")
9. Model receives error, informs user or tries alternative
Group Disable Flow
1. User opens Tool Management, taps group toggle for "Google Drive" → OFF
2. ViewModel calls enabledStateStore.setGroupEnabled("Google Drive", false)
3. SharedPreferences persists: tool_group_enabled_Google Drive = false
4. UI refreshes: group toggle OFF, all child tool toggles grayed out
5. Later, AI requests gdrive_list(...)
6. ToolExecutionEngine checks:
- Tool exists? Yes
- Agent has tool? Yes
- Tool effectively enabled? → isToolEffectivelyEnabled("gdrive_list", "Google Drive")
- isToolEnabled("gdrive_list") → true (individual not changed)
- isGroupEnabled("Google Drive") → false (group disabled)
- Result: false
- Returns ToolResult.error("tool_globally_disabled", ...)
Auto-disable Group Flow
1. Group "Google Drive" has 3 tools, all individually enabled, group enabled
2. User disables gdrive_list → 2 of 3 still enabled → group stays ON
3. User disables gdrive_read → 1 of 3 still enabled → group stays ON
4. User disables gdrive_upload → 0 of 3 enabled → toggleToolEnabled detects all disabled
5. ViewModel auto-calls enabledStateStore.setGroupEnabled("Google Drive", false)
6. UI refreshes: group toggle auto-turns OFF
Implementation Steps
Phase 1: Data Layer
- Create
ToolSourceInfodata model (core/model/ToolSourceInfo.kt) - Create
ToolEnabledStateStore(tool/engine/ToolEnabledStateStore.kt) - Enhance
ToolRegistrywith source info tracking (registeroverload,getToolSourceInfo,getAllToolSourceInfo,getToolGroups) - Update
ToolModule.ktto register tools withToolSourceInfo - Write unit tests for
ToolEnabledStateStoreand enhancedToolRegistry
Phase 2: Execution Engine Integration
- Add
ToolEnabledStateStoredependency toToolExecutionEngine - Insert global enable check in
executeTool()between availability check and parameter validation - Update
ToolModule.ktto wireToolEnabledStateStoreintoToolExecutionEngine - Write unit tests for the new execution engine check
Phase 3: Tool Management UI
- Create
ToolManagementViewModeland UI state classes - Create
ToolManagementScreencomposable (tool list with three sections) - Create
ToolDetailViewcomposable - Create
ToolGroupHeaderandToolListItemcomposables - Add
Route.ManageToolsto navigation - Wire navigation in
NavHost
Phase 4: Settings Integration
- Add
onManageToolsparameter toSettingsScreen - Add “Tools” section with “Manage Tools” entry to Settings layout
- Wire
onManageToolsin the NavHost whereSettingsScreenis called
Phase 5: Agent Configuration Integration
- Add
isGloballyDisabledfield toToolOptionIteminAgentUiState.kt - Update
AgentDetailViewModel.loadAvailableTools()to populateisGloballyDisabled - Update
AgentDetailScreentool list to show grayed-out state and “Globally disabled” label - Disable checkbox interaction for globally disabled tools
Phase 6: Testing
- Layer 1A: JVM unit tests for ToolEnabledStateStore, ToolRegistry enhancements, ToolExecutionEngine global check, ToolManagementViewModel
- Layer 1B: Instrumented tests for SharedPreferences persistence (if applicable)
- Layer 1C: Roborazzi screenshot tests for ToolManagementScreen, ToolDetailView, updated AgentDetailScreen
- Layer 2: adb visual verification flows
- Write test report
Testing Strategy
Unit Tests (Layer 1A)
ToolEnabledStateStore:
isToolEnabledreturnstrueby default (no prior set)setToolEnabled(false)thenisToolEnabledreturnsfalseisGroupEnabledreturnstrueby defaultsetGroupEnabled(false)thenisGroupEnabledreturnsfalseisToolEffectivelyEnabledreturnsfalsewhen tool disabledisToolEffectivelyEnabledreturnsfalsewhen group disabled (tool enabled)isToolEffectivelyEnabledreturnstruewhen both tool and group enabledisToolEffectivelyEnabledignores group whengroupNameis null
ToolRegistry enhancements:
register(tool, sourceInfo)stores source infogetToolSourceInforeturns correct infogetToolGroupsreturns correct groupinggetToolGroupsexcludes non-group tools- Backward compat:
register(tool)without sourceInfo defaults to BUILTIN
ToolExecutionEngine:
- Tool globally disabled: returns
tool_globally_disablederror - Tool in disabled group: returns
tool_globally_disablederror - Tool enabled: proceeds to next checks as before
- All existing tests remain passing
ToolManagementViewModel:
loadToolscategorizes tools into correct sectionstoggleToolEnabledflips state and reloadstoggleGroupEnabledflips group state- Auto-disable group when all children are individually disabled
selectToolpopulatesselectedToolwith correct detailclearSelectedToolsetsselectedToolto null
Screenshot Tests (Layer 1C)
- ToolManagementScreen: built-in tools listed, groups collapsed
- ToolManagementScreen: group expanded with child tools
- ToolManagementScreen: group disabled, child tools grayed out
- ToolManagementScreen: empty state
- ToolDetailView: built-in tool with parameters
- ToolDetailView: group tool showing group name
- AgentDetailScreen: globally disabled tool grayed out with label
Visual Verification (Layer 2)
| Flow | Steps | Expected |
|---|---|---|
| TC-017-01: Navigate to Tool Management | Settings > Manage Tools | Screen shows three sections with all tools |
| TC-017-02: Disable a tool | Toggle off http_request |
Snackbar confirms, toggle reflects OFF state |
| TC-017-03: Re-enable a tool | Toggle on http_request |
Snackbar confirms, toggle reflects ON state |
| TC-017-04: View tool detail | Tap a tool name | Detail view shows name, description, params, permissions, timeout, source |
| TC-017-05: Persistence | Disable a tool, restart app, check | Tool still disabled after restart |
| TC-017-06: Global disable blocks execution | Disable tool, ask AI to use it | AI reports tool not available |
| TC-017-07: Agent config shows disabled | Disable tool, open Agent config | Tool grayed out with “Globally disabled” label |
| TC-017-08: Group expand/collapse | Tap chevron on group | Group expands/collapses |
| TC-017-09: Group toggle OFF | Toggle group OFF | All child tools grayed, non-interactive |
| TC-017-10: Group re-enable | Toggle group ON | Children restore to previous states |
| TC-017-11: Auto-disable group | Disable all children individually | Group toggle auto-turns OFF |
Error Handling
| Error | Source | Handling |
|---|---|---|
| SharedPreferences write failure | ToolEnabledStateStore.setToolEnabled |
Toggle reverts, snackbar: “Failed to save tool state. Please try again.” |
| Empty ToolRegistry | ToolManagementViewModel.loadTools |
Show empty state: “No tools available.” |
| Tool unregistered while detail open | ToolManagementViewModel.selectTool |
Show “This tool is no longer available”, navigate back |
| Model calls disabled tool | ToolExecutionEngine.executeTool |
Return ToolResult.error("tool_globally_disabled", ...) |
| Group manifest removed | JS tool reload | Group disappears on next screen visit. Detail view navigates back if viewing a removed tool |
Performance Considerations
- Tool Management screen load: Reads from in-memory
ToolRegistry+ localSharedPreferences. Expected < 200ms for 50+ tools. - Toggle persistence:
SharedPreferences.apply()is asynchronous, completing in < 50ms. - ToolExecutionEngine check: One
SharedPreferencesread per tool call. SharedPreferences values are cached in memory by Android after first read, so effectively zero-cost. - No network calls: All Tool Management operations are local.
Security Considerations
- Enable/disable state stored in plain
SharedPreferences(app-private,MODE_PRIVATE). Not sensitive data. - No new permissions required.
- Global disable cannot be bypassed by the AI model – the check is enforced at the
ToolExecutionEnginelevel.
Dependencies
Depends On
- RFC-004 (Tool System):
ToolRegistry,ToolDefinition,Toolinterface,ToolExecutionEngine - RFC-009 (Settings):
SettingsScreenentry point - RFC-012 (JS Tool Engine): JS tools appear in the tool list
- RFC-014 (Agent Skill):
load_skilltool in the tool list - RFC-015 (JS Tool Migration): Built-in JS tools classification
- RFC-018 (JS Tool Group): Array manifest format, group loading, group membership
Depended On By
- FEAT-002 (Agent Management): Agent config respects global disable state
Risks and Mitigation
| Risk | Impact | Probability | Mitigation |
|---|---|---|---|
| Group info not available from JsToolLoader | Medium | Medium | RFC-018 must expose group metadata during loading; coordinate implementation order |
| SharedPreferences corruption | Low | Very Low | Default to enabled (fail-open); no data loss |
| Large number of tools (100+) | Low | Low | LazyColumn handles efficiently; SharedPreferences is fast for flat key-value |
Alternative Solutions
Alternative A: Store enable/disable in Room
- Approach: Create a
tool_configRoom table with (tool_name, enabled) columns - Pros: Consistent with other app data storage; supports complex queries
- Cons: Overkill for simple boolean flags; adds migration; tools are not Room entities
- Why not chosen: SharedPreferences is simpler and faster for flat key-value boolean storage
Alternative B: Add enabled field to ToolDefinition
- Approach: Add
var enabled: BooleantoToolDefinitiondata class - Pros: Single source of truth; no separate store
- Cons:
ToolDefinitionis an immutable data class; making it mutable breaks the value-object pattern; state must still persist somewhere - Why not chosen: Violates immutability of domain model; persistence still needed
Future Enhancements
- Tool search and filter bar
- Bulk enable/disable all tools
- Tool categories beyond source type (File, Network, System, Text)
- Tool usage statistics (call count, last used)
- Tool health indicators for JS tools with load errors
- Per-agent disable override display in tool detail
- Export/import tool enable/disable configuration
Change History
| Date | Version | Changes | Owner |
|---|---|---|---|
| 2026-02-28 | 0.1 | Initial version | - |