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 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-23 01:01:52 +09:00
parent f764e33454
commit 65fd24165a
7 changed files with 135 additions and 10 deletions

View file

@ -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

View file

@ -42,6 +42,7 @@ class AssistantConnectionImpl(
override val connectionState: StateFlow<ConnectionState> = 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))
}

View file

@ -114,9 +114,7 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
}
// Android device control tool
if cfg.Tools.Android.Enabled {
androidTool := tools.NewAndroidTool()
androidTool.SetSendCallback(func(channel, chatID, content, msgType string) error {
sendCallbackWithType := func(channel, chatID, content, msgType string) error {
msgBus.PublishOutbound(bus.OutboundMessage{
Channel: channel,
ChatID: chatID,
@ -124,7 +122,10 @@ func createToolRegistry(workspace string, restrict bool, cfg *config.Config, msg
Type: msgType,
})
return nil
})
}
if cfg.Tools.Android.Enabled {
androidTool := tools.NewAndroidTool()
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.

View file

@ -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":

View file

@ -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.
//

66
pkg/tools/exit.go Normal file
View file

@ -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.")
}

View file

@ -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