From 65fd24165aadf209a8baaca87cb37e87cbfb6874 Mon Sep 17 00:00:00 2001 From: Kohei Date: Mon, 23 Feb 2026 01:01:52 +0900 Subject: [PATCH] feat: add exit tool for LLM-driven assistant shutdown (voice/assistant mode only) Add an exit tool that allows the LLM to terminate the assistant service when the user expresses intent to end the conversation. The tool is programmatically restricted to voice/assistant input modes via the new ActivatableTool interface, which filters tools from API definitions, system prompt summaries, and provider defs when IsActive() returns false. Co-Authored-By: Claude Opus 4.6 --- .../android/assistant/AssistantService.kt | 15 +++++ .../repository/AssistantConnectionImpl.kt | 2 + pkg/agent/loop.go | 44 ++++++++++--- pkg/agent/status.go | 2 + pkg/tools/base.go | 7 ++ pkg/tools/exit.go | 66 +++++++++++++++++++ pkg/tools/registry.go | 9 +++ 7 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 pkg/tools/exit.go diff --git a/android/app/src/main/java/io/picoclaw/android/assistant/AssistantService.kt b/android/app/src/main/java/io/picoclaw/android/assistant/AssistantService.kt index 00adb63d4..645e8b255 100644 --- a/android/app/src/main/java/io/picoclaw/android/assistant/AssistantService.kt +++ b/android/app/src/main/java/io/picoclaw/android/assistant/AssistantService.kt @@ -68,6 +68,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import org.koin.android.ext.android.inject class AssistantService : LifecycleService(), SavedStateRegistryOwner { @@ -130,6 +131,9 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { response.error ?: "unknown error" } } + (connection as AssistantConnectionImpl).onExit = { farewell -> + handleExitCommand(farewell) + } sttWrapper = SpeechRecognizerWrapper(this) ttsWrapper = TextToSpeechWrapper(this, ttsSettingsRepo.ttsConfig) @@ -225,6 +229,17 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { stopSelf() } + private fun handleExitCommand(farewell: String?) { + if (!farewell.isNullOrBlank()) { + serviceScope.launch { + ttsWrapper.speak(farewell) + shutdown() + } + } else { + shutdown() + } + } + private fun moveOverlayTo(top: Boolean) { val view = overlayView ?: return val lp = view.layoutParams as? WindowManager.LayoutParams ?: return diff --git a/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/AssistantConnectionImpl.kt b/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/AssistantConnectionImpl.kt index d4e5e09af..c3322550e 100644 --- a/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/AssistantConnectionImpl.kt +++ b/android/core/data/src/main/java/io/picoclaw/android/core/data/repository/AssistantConnectionImpl.kt @@ -42,6 +42,7 @@ class AssistantConnectionImpl( override val connectionState: StateFlow = wsClient.connectionState var onToolRequest: ToolRequestCallback? = null + var onExit: ((String?) -> Unit)? = null init { scope.launch { @@ -50,6 +51,7 @@ class AssistantConnectionImpl( "status" -> _statusText.value = dto.content "status_end" -> _statusText.value = null "tool_request" -> handleToolRequest(dto.content) + "exit" -> onExit?.invoke(dto.content) else -> { _messages.emit(AssistantMessage(content = dto.content, type = dto.type)) } diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index e7790446f..3853f4660 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -114,17 +114,18 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg } // Android device control tool + sendCallbackWithType := func(channel, chatID, content, msgType string) error { + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: content, + Type: msgType, + }) + return nil + } if cfg.Tools.Android.Enabled { androidTool := tools.NewAndroidTool() - androidTool.SetSendCallback(func(channel, chatID, content, msgType string) error { - msgBus.PublishOutbound(bus.OutboundMessage{ - Channel: channel, - ChatID: chatID, - Content: content, - Type: msgType, - }) - return nil - }) + androidTool.SetSendCallback(sendCallbackWithType) registry.Register(androidTool) } @@ -166,10 +167,23 @@ func NewAgentLoop(cfg *config.Config, msgBus *bus.MessageBus, provider providers // Subagent doesn't need spawn/subagent tools to avoid recursion subagentManager.SetTools(subagentTools) - // Register spawn tool (for main agent) + // Register spawn tool (for main agent only) spawnTool := tools.NewSpawnTool(subagentManager) toolsRegistry.Register(spawnTool) + // Register exit tool (for main agent only, voice/assistant mode) + exitTool := tools.NewExitTool() + exitTool.SetSendCallback(func(channel, chatID, content, msgType string) error { + msgBus.PublishOutbound(bus.OutboundMessage{ + Channel: channel, + ChatID: chatID, + Content: content, + Type: msgType, + }) + return nil + }) + toolsRegistry.Register(exitTool) + // Register subagent tool (synchronous execution) subagentTool := tools.NewSubagentTool(subagentManager) toolsRegistry.Register(subagentTool) @@ -1030,6 +1044,16 @@ func (al *AgentLoop) updateToolContexts(channel, chatID string, metadata map[str ct.SetContext(channel, chatID) } } + if tool, ok := al.tools.Get("exit"); ok { + if et, ok := tool.(*tools.ExitTool); ok { + et.SetContext(channel, chatID) + if metadata != nil { + et.SetInputMode(metadata["input_mode"]) + } else { + et.SetInputMode("") + } + } + } } // maybeSummarize triggers summarization if the session history exceeds thresholds. diff --git a/pkg/agent/status.go b/pkg/agent/status.go index 6ec47e7c2..a0ca2eea9 100644 --- a/pkg/agent/status.go +++ b/pkg/agent/status.go @@ -58,6 +58,8 @@ func statusLabel(toolName string, args map[string]interface{}) string { return "サブタスク実行中..." case "android": return androidStatusLabel(args) + case "exit": + return "アシスタント終了中..." case "mcp": return mcpStatusLabel(args) case "i2c": diff --git a/pkg/tools/base.go b/pkg/tools/base.go index b13174633..47933e332 100644 --- a/pkg/tools/base.go +++ b/pkg/tools/base.go @@ -17,6 +17,13 @@ type ContextualTool interface { SetContext(channel, chatID string) } +// ActivatableTool is an optional interface that tools can implement +// to conditionally hide themselves from the LLM tool list. +// When IsActive() returns false, the tool is excluded from provider definitions. +type ActivatableTool interface { + IsActive() bool +} + // AsyncCallback is a function type that async tools use to notify completion. // When an async tool finishes its work, it calls this callback with the result. // diff --git a/pkg/tools/exit.go b/pkg/tools/exit.go new file mode 100644 index 000000000..fe6f53ca6 --- /dev/null +++ b/pkg/tools/exit.go @@ -0,0 +1,66 @@ +package tools + +import "context" + +// ExitTool sends an "exit" message via WebSocket to terminate the assistant. +// Only active in voice/assistant input modes (controlled via ActivatableTool). +type ExitTool struct { + sendCallback SendCallbackWithType + channel string + chatID string + inputMode string +} + +func NewExitTool() *ExitTool { + return &ExitTool{} +} + +func (t *ExitTool) Name() string { return "exit" } + +func (t *ExitTool) Description() string { + return "Exit the assistant service. Call this when the user wants to end the conversation (e.g. \"おやすみ\", \"終わり\", \"閉じて\"). Provide a short farewell message." +} + +func (t *ExitTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "message": map[string]interface{}{ + "type": "string", + "description": "A short farewell message to speak before exiting (e.g. \"おやすみなさい\")", + }, + }, + "required": []string{"message"}, + } +} + +func (t *ExitTool) SetContext(channel, chatID string) { + t.channel = channel + t.chatID = chatID +} + +func (t *ExitTool) SetSendCallback(cb SendCallbackWithType) { + t.sendCallback = cb +} + +func (t *ExitTool) SetInputMode(mode string) { + t.inputMode = mode +} + +func (t *ExitTool) IsActive() bool { + return t.inputMode == "voice" || t.inputMode == "assistant" +} + +func (t *ExitTool) Execute(ctx context.Context, args map[string]interface{}) *ToolResult { + if t.sendCallback == nil { + return ErrorResult("exit tool: send callback not configured") + } + if t.channel == "" || t.chatID == "" { + return ErrorResult("exit tool: no active channel context") + } + + message, _ := args["message"].(string) + + t.sendCallback(t.channel, t.chatID, message, "exit") + return SilentResult("Exit signal sent.") +} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index c8cf92863..2d5717256 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -107,6 +107,9 @@ func (r *ToolRegistry) GetDefinitions() []map[string]interface{} { definitions := make([]map[string]interface{}, 0, len(r.tools)) for _, tool := range r.tools { + if at, ok := tool.(ActivatableTool); ok && !at.IsActive() { + continue + } definitions = append(definitions, ToolToSchema(tool)) } return definitions @@ -120,6 +123,9 @@ func (r *ToolRegistry) ToProviderDefs() []providers.ToolDefinition { definitions := make([]providers.ToolDefinition, 0, len(r.tools)) for _, tool := range r.tools { + if at, ok := tool.(ActivatableTool); ok && !at.IsActive() { + continue + } schema := ToolToSchema(tool) // Safely extract nested values with type checks @@ -171,6 +177,9 @@ func (r *ToolRegistry) GetSummaries() []string { summaries := make([]string, 0, len(r.tools)) for _, tool := range r.tools { + if at, ok := tool.(ActivatableTool); ok && !at.IsActive() { + continue + } summaries = append(summaries, fmt.Sprintf("- `%s` - %s", tool.Name(), tool.Description())) } return summaries