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:
parent
f764e33454
commit
65fd24165a
7 changed files with 135 additions and 10 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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":
|
||||
|
|
|
|||
|
|
@ -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
66
pkg/tools/exit.go
Normal 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.")
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue