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