feat: replace current_activity with screenshot and get_ui_tree actions

Remove the current_activity action and add screenshot (returns base64
JPEG as multimodal tool result) and get_ui_tree (accessibility tree
with optional resource_id/bounds/index targeting and depth/node limits).

- Add Media field to ToolResult and propagate through message pipeline
- Extend HTTP provider to support multimodal content on tool messages
- Add input validation (mutual exclusivity, bounds, negative index)
- Fix existing bug: status.go used "list_apps" instead of "search_apps"

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kohei 2026-02-22 13:36:46 +09:00
parent aa83f448f9
commit 45adabf82c
8 changed files with 241 additions and 22 deletions

View file

@ -117,6 +117,10 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner {
toolRequestHandler = ToolRequestHandler( toolRequestHandler = ToolRequestHandler(
context = applicationContext, context = applicationContext,
deviceController = deviceController, deviceController = deviceController,
screenshotSource = screenshotSource,
setOverlayVisibility = { visible ->
overlayView?.visibility = if (visible) View.VISIBLE else View.INVISIBLE
},
onAccessibilityNeeded = { showAccessibilityGuide = true } onAccessibilityNeeded = { showAccessibilityGuide = true }
) )
(connection as AssistantConnectionImpl).onToolRequest = { request -> (connection as AssistantConnectionImpl).onToolRequest = { request ->

View file

@ -54,14 +54,8 @@ class DeviceController {
return service?.performGlobalAction(AccessibilityService.GLOBAL_ACTION_RECENTS) ?: false return service?.performGlobalAction(AccessibilityService.GLOBAL_ACTION_RECENTS) ?: false
} }
fun getCurrentPackage(): String? { fun getRootNode(): AccessibilityNodeInfo? {
val svc = service ?: return null return service?.rootInActiveWindow
val ownPackage = svc.packageName
// Skip our own overlay window and find the actual foreground app
return svc.windows
.filter { it.type == android.view.accessibility.AccessibilityWindowInfo.TYPE_APPLICATION }
.firstOrNull { it.root?.packageName?.toString() != ownPackage }
?.root?.packageName?.toString()
} }
suspend fun inputText(text: String): Boolean { suspend fun inputText(text: String): Boolean {

View file

@ -4,21 +4,33 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.ApplicationInfo import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.graphics.Bitmap
import android.graphics.Rect
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.util.Base64
import android.util.Log import android.util.Log
import android.view.accessibility.AccessibilityNodeInfo
import io.picoclaw.android.core.data.remote.dto.ToolRequest import io.picoclaw.android.core.data.remote.dto.ToolRequest
import io.picoclaw.android.core.data.remote.dto.ToolResponse import io.picoclaw.android.core.data.remote.dto.ToolResponse
import io.picoclaw.android.feature.chat.voice.ScreenshotSource
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.contentOrNull import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.doubleOrNull import kotlinx.serialization.json.doubleOrNull
import kotlinx.serialization.json.jsonPrimitive import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.longOrNull
import java.io.ByteArrayOutputStream
class ToolRequestHandler( class ToolRequestHandler(
private val context: Context, private val context: Context,
private val deviceController: DeviceController, private val deviceController: DeviceController,
private val screenshotSource: ScreenshotSource,
private val setOverlayVisibility: (Boolean) -> Unit,
private val onAccessibilityNeeded: () -> Unit private val onAccessibilityNeeded: () -> Unit
) { ) {
@ -28,7 +40,8 @@ class ToolRequestHandler(
"search_apps" -> handleSearchApps(request) "search_apps" -> handleSearchApps(request)
"app_info" -> handleAppInfo(request) "app_info" -> handleAppInfo(request)
"launch_app" -> handleLaunchApp(request) "launch_app" -> handleLaunchApp(request)
"current_activity" -> handleCurrentActivity(request) "screenshot" -> handleScreenshot(request)
"get_ui_tree" -> handleGetUiTree(request)
"tap" -> handleTap(request) "tap" -> handleTap(request)
"swipe" -> handleSwipe(request) "swipe" -> handleSwipe(request)
"text" -> handleText(request) "text" -> handleText(request)
@ -140,13 +153,144 @@ class ToolRequestHandler(
return ToolResponse(request.requestId, true, result = "Launched $packageName") return ToolResponse(request.requestId, true, result = "Launched $packageName")
} }
private suspend fun handleCurrentActivity(request: ToolRequest): ToolResponse { private suspend fun handleScreenshot(request: ToolRequest): ToolResponse {
requireAccessibility(request)?.let { return it } requireAccessibility(request)?.let { return it }
val pkg = deviceController.getCurrentPackage() return try {
?: return ToolResponse(request.requestId, false, error = "Could not get current activity") withContext(Dispatchers.Main) { setOverlayVisibility(false) }
delay(150)
val bitmap = screenshotSource.takeScreenshot()
?: return ToolResponse(request.requestId, false, error = "Screenshot capture failed")
try {
val base64 = withContext(Dispatchers.IO) {
val stream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, stream)
Base64.encodeToString(stream.toByteArray(), Base64.NO_WRAP)
}
ToolResponse(request.requestId, true, result = base64)
} finally {
bitmap.recycle()
}
} finally {
withContext(Dispatchers.Main) { setOverlayVisibility(true) }
}
}
return ToolResponse(request.requestId, true, result = "Current package: $pkg") private suspend fun handleGetUiTree(request: ToolRequest): ToolResponse {
requireAccessibility(request)?.let { return it }
val resourceId = request.params?.get("resource_id")?.jsonPrimitive?.contentOrNull
val index = request.params?.get("index")?.jsonPrimitive?.intOrNull ?: 0
val boundsX = request.params?.get("bounds_x")?.jsonPrimitive?.doubleOrNull
val boundsY = request.params?.get("bounds_y")?.jsonPrimitive?.doubleOrNull
val maxDepth = request.params?.get("max_depth")?.jsonPrimitive?.intOrNull ?: 30
val maxNodes = request.params?.get("max_nodes")?.jsonPrimitive?.intOrNull ?: 2000
return try {
withContext(Dispatchers.Main) { setOverlayVisibility(false) }
delay(150)
val root = deviceController.getRootNode()
?: return ToolResponse(request.requestId, false, error = "Could not get UI tree")
try {
val startNode = resolveStartNode(root, resourceId, index, boundsX, boundsY)
?: return ToolResponse(request.requestId, false, error = buildString {
if (resourceId != null) append("No node found with resource_id=$resourceId (index=$index)")
else append("No node found at bounds ($boundsX, $boundsY)")
})
try {
val sb = StringBuilder()
val nodeCount = intArrayOf(0)
dumpNode(startNode, sb, 0, maxDepth, maxNodes, nodeCount)
if (nodeCount[0] >= maxNodes) {
sb.appendLine("[truncated: max_nodes=$maxNodes reached]")
}
ToolResponse(request.requestId, true, result = sb.toString())
} finally {
if (startNode !== root) startNode.recycle()
}
} finally {
root.recycle()
}
} finally {
withContext(Dispatchers.Main) { setOverlayVisibility(true) }
}
}
private fun resolveStartNode(
root: AccessibilityNodeInfo,
resourceId: String?,
index: Int,
boundsX: Double?,
boundsY: Double?
): AccessibilityNodeInfo? {
if (resourceId != null) {
val matches = root.findAccessibilityNodeInfosByViewId(resourceId)
if (matches.isNullOrEmpty()) return null
val target = matches.getOrNull(index)
// Recycle unused matches
for ((i, node) in matches.withIndex()) {
if (i != index) node.recycle()
}
return target
}
if (boundsX != null && boundsY != null) {
return findNodeAtPoint(root, boundsX.toInt(), boundsY.toInt())
}
return root
}
private fun findNodeAtPoint(node: AccessibilityNodeInfo, x: Int, y: Int): AccessibilityNodeInfo? {
val bounds = Rect()
node.getBoundsInScreen(bounds)
if (!bounds.contains(x, y)) return null
// Find the deepest (smallest) child that contains the point
for (i in 0 until node.childCount) {
val child = node.getChild(i) ?: continue
val found = findNodeAtPoint(child, x, y)
if (found != null) return found
child.recycle()
}
return node
}
private fun dumpNode(
node: AccessibilityNodeInfo,
sb: StringBuilder,
depth: Int,
maxDepth: Int,
maxNodes: Int,
nodeCount: IntArray
) {
if (nodeCount[0] >= maxNodes) return
nodeCount[0]++
val indent = " ".repeat(depth)
val bounds = Rect()
node.getBoundsInScreen(bounds)
sb.appendLine(
"${indent}[${node.className}] " +
"text=${node.text ?: ""} " +
"desc=${node.contentDescription ?: ""} " +
"bounds=${bounds} " +
"clickable=${node.isClickable} " +
"enabled=${node.isEnabled} " +
"visible=${node.isVisibleToUser} " +
"id=${node.viewIdResourceName ?: ""}"
)
if (depth >= maxDepth) {
if (node.childCount > 0) {
sb.appendLine("${indent} [truncated: ${node.childCount} children at depth $depth]")
}
return
}
for (i in 0 until node.childCount) {
if (nodeCount[0] >= maxNodes) return
val child = node.getChild(i) ?: continue
try {
dumpNode(child, sb, depth + 1, maxDepth, maxNodes, nodeCount)
} finally {
child.recycle()
}
}
} }
private suspend fun handleTap(request: ToolRequest): ToolResponse { private suspend fun handleTap(request: ToolRequest): ToolResponse {

View file

@ -957,6 +957,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M
toolResultMsg := providers.Message{ toolResultMsg := providers.Message{
Role: "tool", Role: "tool",
Content: contentForLLM, Content: contentForLLM,
Media: toolResult.Media,
ToolCallID: tc.ID, ToolCallID: tc.ID,
} }
messages = append(messages, toolResultMsg) messages = append(messages, toolResultMsg)

View file

@ -120,8 +120,8 @@ func cronStatusLabel(args map[string]interface{}) string {
func androidStatusLabel(args map[string]interface{}) string { func androidStatusLabel(args map[string]interface{}) string {
switch strArg(args, "action") { switch strArg(args, "action") {
case "list_apps": case "search_apps":
return "アプリ一覧取得中..." return "アプリ検索中..."
case "app_info": case "app_info":
if p := strArg(args, "package_name"); p != "" { if p := strArg(args, "package_name"); p != "" {
return fmt.Sprintf("アプリ情報取得中...%s", truncLabel(p, 25)) return fmt.Sprintf("アプリ情報取得中...%s", truncLabel(p, 25))
@ -132,8 +132,10 @@ func androidStatusLabel(args map[string]interface{}) string {
return fmt.Sprintf("アプリ起動中...%s", truncLabel(p, 25)) return fmt.Sprintf("アプリ起動中...%s", truncLabel(p, 25))
} }
return "アプリ起動中..." return "アプリ起動中..."
case "current_activity": case "screenshot":
return "画面情報取得中..." return "スクリーンショット撮影中..."
case "get_ui_tree":
return "UI要素取得中..."
case "tap": case "tap":
return "タップ中..." return "タップ中..."
case "swipe": case "swipe":

View file

@ -134,8 +134,8 @@ func (p *HTTPProvider) buildAPIMessages(messages []Message) []map[string]interfa
"role": msg.Role, "role": msg.Role,
} }
// Only user messages with media get the array-style content // Messages with media get the array-style content (user or tool)
if msg.Role == "user" && len(msg.Media) > 0 { if len(msg.Media) > 0 && (msg.Role == "user" || msg.Role == "tool") {
parts := make([]map[string]interface{}, 0, 1+len(msg.Media)) parts := make([]map[string]interface{}, 0, 1+len(msg.Media))
if msg.Content != "" { if msg.Content != "" {
parts = append(parts, map[string]interface{}{ parts = append(parts, map[string]interface{}{

View file

@ -45,7 +45,8 @@ func (t *AndroidTool) Description() string {
- search_apps: Search installed apps by name or package name (requires query) - search_apps: Search installed apps by name or package name (requires query)
- app_info: Get app details (requires package_name) - app_info: Get app details (requires package_name)
- launch_app: Launch an app (requires package_name) - launch_app: Launch an app (requires package_name)
- current_activity: Get the currently active app/window - screenshot: Capture a screenshot of the current screen (no params)
- get_ui_tree: Get the accessibility UI tree (optional: resource_id, index, bounds_x/bounds_y, max_depth, max_nodes)
- tap: Tap a screen coordinate (requires x, y) - tap: Tap a screen coordinate (requires x, y)
- swipe: Swipe between coordinates (requires x, y, x2, y2; optional duration_ms) - swipe: Swipe between coordinates (requires x, y, x2, y2; optional duration_ms)
- text: Input text into the focused field (requires text) - text: Input text into the focused field (requires text)
@ -62,7 +63,8 @@ func (t *AndroidTool) Parameters() map[string]interface{} {
"action": map[string]interface{}{ "action": map[string]interface{}{
"type": "string", "type": "string",
"enum": []string{ "enum": []string{
"search_apps", "app_info", "launch_app", "current_activity", "search_apps", "app_info", "launch_app",
"screenshot", "get_ui_tree",
"tap", "swipe", "text", "keyevent", "tap", "swipe", "text", "keyevent",
"broadcast", "intent", "broadcast", "intent",
}, },
@ -125,6 +127,30 @@ func (t *AndroidTool) Parameters() map[string]interface{} {
"type": "object", "type": "object",
"description": "Extra key-value pairs for broadcast/intent", "description": "Extra key-value pairs for broadcast/intent",
}, },
"resource_id": map[string]interface{}{
"type": "string",
"description": "View resource ID to start UI tree from (for get_ui_tree, e.g. com.example:id/button)",
},
"index": map[string]interface{}{
"type": "integer",
"description": "Which match to use when resource_id has multiple hits (for get_ui_tree, default 0)",
},
"bounds_x": map[string]interface{}{
"type": "number",
"description": "X coordinate to find the containing node (for get_ui_tree, alternative to resource_id)",
},
"bounds_y": map[string]interface{}{
"type": "number",
"description": "Y coordinate to find the containing node (for get_ui_tree, alternative to resource_id)",
},
"max_depth": map[string]interface{}{
"type": "integer",
"description": "Maximum traversal depth (for get_ui_tree, default 30)",
},
"max_nodes": map[string]interface{}{
"type": "integer",
"description": "Maximum number of nodes to output (for get_ui_tree, default 2000)",
},
}, },
"required": []string{"action"}, "required": []string{"action"},
} }
@ -181,9 +207,45 @@ func (t *AndroidTool) validateAndBuildParams(action string, args map[string]inte
} }
params["package_name"] = pkg params["package_name"] = pkg
case "current_activity": case "screenshot":
// No params needed // No params needed
case "get_ui_tree":
// Start node selection: resource_id or bounds (mutually exclusive)
hasResourceID := false
hasBounds := false
if rid, ok := args["resource_id"].(string); ok && rid != "" {
params["resource_id"] = rid
hasResourceID = true
if idx, ok := toFloat64(args["index"]); ok {
idxInt := int(idx)
if idxInt < 0 {
return nil, fmt.Errorf("get_ui_tree: index must be non-negative, got %d", idxInt)
}
params["index"] = idxInt
}
}
if bx, bxOk := toFloat64(args["bounds_x"]); bxOk {
if by, byOk := toFloat64(args["bounds_y"]); byOk {
params["bounds_x"] = bx
params["bounds_y"] = by
hasBounds = true
}
}
if hasResourceID && hasBounds {
return nil, fmt.Errorf("get_ui_tree: cannot specify both resource_id and bounds_x/bounds_y")
}
if md, ok := toFloat64(args["max_depth"]); ok {
params["max_depth"] = int(md)
}
if mn, ok := toFloat64(args["max_nodes"]); ok {
mnInt := int(mn)
if mnInt < 1 {
return nil, fmt.Errorf("get_ui_tree: max_nodes must be at least 1, got %d", mnInt)
}
params["max_nodes"] = mnInt
}
case "tap": case "tap":
x, xOk := toFloat64(args["x"]) x, xOk := toFloat64(args["x"])
y, yOk := toFloat64(args["y"]) y, yOk := toFloat64(args["y"])
@ -306,6 +368,14 @@ func (t *AndroidTool) sendAndWait(ctx context.Context, action string, params map
ForLLM: "accessibility_required: The accessibility service is not enabled. The settings dialog has been shown to the user. Do not retry automatically - wait for the user to enable the service and try again.", ForLLM: "accessibility_required: The accessibility service is not enabled. The settings dialog has been shown to the user. Do not retry automatically - wait for the user to enable the service and try again.",
} }
} }
// Screenshot returns base64 JPEG data — wrap as multimodal result
if action == "screenshot" {
return &ToolResult{
ForLLM: "Screenshot captured.",
Media: []string{"data:image/jpeg;base64," + content},
Silent: true,
}
}
return SilentResult(content) return SilentResult(content)
case <-time.After(androidToolTimeout): case <-time.After(androidToolTimeout):
DeviceResponseWaiter.Cleanup(requestID) DeviceResponseWaiter.Cleanup(requestID)

View file

@ -27,6 +27,10 @@ type ToolResult struct {
// When true, the tool will complete later and notify via callback. // When true, the tool will complete later and notify via callback.
Async bool `json:"async"` Async bool `json:"async"`
// Media contains base64 data URLs (e.g. "data:image/jpeg;base64,...") to
// pass multimodal content (screenshots, etc.) to the LLM.
Media []string `json:"media,omitempty"`
// Err is the underlying error (not JSON serialized). // Err is the underlying error (not JSON serialized).
// Used for internal error handling and logging. // Used for internal error handling and logging.
Err error `json:"-"` Err error `json:"-"`