Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ This changelog tracks **Minis for Android** as an independently maintained Andro

## Unreleased

- Fixed a chat that kept failing with `400 No tool output found for function call …` (or `No tool call found for function call output …`) on OpenAI Responses models and never recovered, while a new chat worked. A tool call stored as `call_x|fc_y` and its result stored as `call_x` were treated as two different ids, so the history repair deleted a real result and the request went out unpaired on every retry. Calls and results are now matched on the `call_x` part, and the Responses request is checked once more just before sending: a result with no call and a repeated result are dropped, a call with no result gets a placeholder. Ported from OpenMinis 1.14.
- Device protection (Settings > Permissions, on by default): privileged commands from the agent, `android-root-cli` and the Android tools can no longer write partitions or the system partitions, remove `/data/system`, `/data/misc`, `/metadata` or `/data/adb`, uninstall or disable core system apps, factory-reset or remove user 0. Nothing is asked: the agent gets `DEVICE_PROTECTED: <category>: <target>` and tries another way. Everything else stays open. Refusals are listed under the switch. When `/data` is nearly full the app freezes its own terminals and refuses new bulk writes until space is freed. `android-root-cli partition read` exports a partition read-only.
- Root only: Shizuku (and AXManager / Sui) support is removed; Root is the app's one privileged path. The code before this change is kept on the branch `archive/before-root-only`.
- `android-shizuku-cli` is now `android-root-cli`: the same subcommands and JSON, run as root through `su`. Its Agent permission switch carries over.
Expand Down
1 change: 1 addition & 0 deletions PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ Minis for Android 含有从开源项目 **OpenMinis** 衍生的代码:
- 长按用户消息的「压缩以上内容」入口与确认框(`[T-android-compact-above]`);`compactBefore` 本仓库早已具备,只引入入口。
- 回复 Token 用量的读取与格式(`ChatTokenUsage`、`[T-android-usage-capsule-time]`);展示位置改为回复长按菜单底部,不引入其「点空白处」的手势与聊天列表渲染改动。
- 子代理(`SubAgentDefinition` / `SubAgentRoster` 的规范化规则、`subagent_task` 工具合同与文案、回调文案,`[T-sub-agents-v1]`):花名册规则、工具 schema 与回调文案取自上游;运行时与注册表按本仓库的 `AgentRunner` 重写,模型绑定由「模型组」改为模型条目,工具沿用本仓库既有名称 `subagent`。差异记录在 `docs/development/SUB-AGENTS.md`。
- 工具调用与结果的配对(`ToolPairing`、`ResponsesToolPairing`,`[T-android-responses-tool-id-normalize]`、`[T-android-responses-orphan-tool-output]`):两个文件原样取自上游;`OutgoingHistory.dropOrphanedToolParts` 与 `ToolBatchRepair` 按上游 `ChatViewModel` 的同名逻辑改为按 `ToolPairing.key` 比较,Responses 请求体在写入 `input` 前经过 `ResponsesToolPairing.sanitize`。测试按本仓库的结构改写。

## Referenced projects

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.openminis.app.agent

import com.openminis.app.data.model.AgentContentPart
import com.openminis.app.data.model.LLMMessage
import com.openminis.app.provider.ToolPairing

/**
* [T-tool-batch-repair-android] Keeps the conversation well-formed for the provider, and
Expand Down Expand Up @@ -57,8 +58,11 @@ object ToolBatchRepair {
}
val answered = repaired.getOrNull(index + 1)?.contentParts
?.filterIsInstance<AgentContentPart.ToolResult>()
?.map { it.id }?.toSet() ?: emptySet()
val missing = toolUses.filter { it.id !in answered }
?.map { ToolPairing.key(it.id) }?.toSet() ?: emptySet()
// [T-android-responses-tool-id-normalize] Paired on ToolPairing.key, not the raw
// id: the Responses path stores "call|fc" and a history can hold both forms for one
// call. On raw ids a correctly paired result was called an orphan and deleted below.
val missing = toolUses.filter { ToolPairing.key(it.id) !in answered }
if (missing.isEmpty()) {
index++
continue
Expand Down Expand Up @@ -95,7 +99,7 @@ object ToolBatchRepair {
// some of them; drop it, and drop the message if that was all it carried.
val knownCalls = repaired.flatMap { it.contentParts }
.filterIsInstance<AgentContentPart.ToolUse>()
.map { it.id }
.map { ToolPairing.key(it.id) }
.toSet()
var droppedResults = 0
var droppedMessages = 0
Expand All @@ -104,7 +108,7 @@ object ToolBatchRepair {
val message = iterator.next()
if (message.role != LLMMessage.Role.USER) continue
val kept = message.contentParts.filter { part ->
val drop = part is AgentContentPart.ToolResult && part.id !in knownCalls
val drop = part is AgentContentPart.ToolResult && ToolPairing.key(part.id) !in knownCalls
if (drop) droppedResults++
!drop
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package com.openminis.app.provider

/**
* [T-android-responses-tool-id-normalize] The one notion of "this tool result
* answers that tool call", shared by every pairing check. Port of iOS
* `AIChatViewModel.pairingKey` (bc142f750).
*
* The OpenAI Responses path stores a COMBINED id on each tool call and its
* result, `"<call_id>|<fc_id>"` (OpenAIProvider.combineResponsesAPIIds), and
* the wire splits it and matches on the call_id half. A history can hold both
* forms for the same call - a model switch or a fallback to a Chat
* Completions model, history reloaded from the DB, a relay that rewrote ids -
* so comparing raw ids calls a correctly paired call/result an orphan:
* the history repair then deleted a real result and injected an "interrupted"
* placeholder for a tool that had worked.
*
* The key is the part before "|". Chat Completions, Anthropic and Gemini ids
* contain no "|", so for them this is the identity and nothing changes.
*/
object ToolPairing {
fun key(id: String): String {
val sep = id.indexOf('|')
return if (sep < 0) id else id.substring(0, sep)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -505,7 +505,21 @@ internal fun OpenAIProvider.buildResponsesAPIBody(
})
}
}
body.put("input", input)
// [T-android-responses-orphan-tool-output] Last gate on the tool pairing, over the
// ids this request actually carries (split on "|" and capped). Without it a stranded
// or duplicated tool result 400s every send until the chat is cleared.
// See ResponsesToolPairing (ported from OpenMinis 1.14).
val paired = ResponsesToolPairing.sanitize(input)
if (paired.changed) {
com.openminis.app.logging.AppLogger.warning(
"OpenAIProvider",
"[sanitize-responses] repaired outgoing tool pairing: " +
"droppedOrphanOutputs=${paired.droppedOrphanOutputs} " +
"droppedDuplicateOutputs=${paired.droppedDuplicateOutputs} " +
"placeholderCalls=${paired.placeholderCalls} items=${input.length()}",
)
}
body.put("input", paired.items)

// [T-android-model-use-passthrough-mode GH#72] Same verbatim merge as the
// chat-completions builder. Skipped for Codex OAuth inside mergeChatExtraBody.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package com.openminis.app.provider.openai

import org.json.JSONArray
import org.json.JSONObject

/**
* [T-android-responses-orphan-tool-output] Last gate before a Responses
* request goes out: every `function_call_output` must answer a
* `function_call` with the same `call_id` in the array, at most once, and every
* `function_call` except the trailing run must be answered. Port of iOS
* `sanitizeResponsesToolPairing` (bc142f750, first-output-wins from ffbed35ca).
*
* Why it is needed: the Responses builder emitted `function_call_output`
* unconditionally. An upstream answers a stranded one with
* No tool call found for function call output with call_id call_… .
* and, because the request is rebuilt from the same history every time, the
* 400 repeated on every retry and every fallback model until the chat was
* cleared (the iOS field report). ChatViewModel's history repair catches most
* orphans one layer up, but it works on stored ids while this sees exactly the
* ids the request carries (split on "|" and capped), and it cannot see every
* source of a stranded or duplicated result (a retry, resume or restore that
* left an extra result row).
*
* Repair, same rules as iOS:
* - an output whose call is not in the request is DROPPED - nothing can
* reconstruct the call;
* - a second output for the same call_id is DROPPED - the first one wins, as
* in the Chat Completions path;
* - an unanswered call gets a placeholder output instead of being deleted,
* which would silently discard the assistant's own turn. The TRAILING run of
* calls is exempt: a request ending on function_call is what the API expects
* mid-round, and a placeholder there would tell the model a tool it is about
* to run has already failed.
*
* Placeholders are buffered until the run of function_call items closes and
* land before the first following item, so parallel calls stay
* `function_call ×N, function_call_output ×N` - an output in the middle of the
* call run, or a tool-image carrier between outputs, is itself a 400
* ([T-android-responses-toolresult-image-split]). Order is otherwise kept as
* built; no items are moved.
*/
internal object ResponsesToolPairing {

const val PLACEHOLDER_OUTPUT =
"Tool execution result is unavailable (history was truncated or interrupted)."

class Result(
val items: JSONArray,
val droppedOrphanOutputs: List<String>,
val droppedDuplicateOutputs: List<String>,
val placeholderCalls: List<String>,
) {
val changed: Boolean
get() = droppedOrphanOutputs.isNotEmpty() || droppedDuplicateOutputs.isNotEmpty() ||
placeholderCalls.isNotEmpty()
}

fun sanitize(items: JSONArray): Result {
val callIds = HashSet<String>()
val outputCounts = HashMap<String, Int>()
for (i in 0 until items.length()) {
val item = items.optJSONObject(i) ?: continue
when (item.optString("type")) {
"function_call" -> callIds.add(item.optString("call_id"))
"function_call_output" -> {
val id = item.optString("call_id")
outputCounts[id] = (outputCounts[id] ?: 0) + 1
}
}
}

// Calls at the very end are waiting for their results, not orphaned.
val trailing = HashSet<String>()
for (i in items.length() - 1 downTo 0) {
val item = items.optJSONObject(i) ?: break
when (item.optString("type")) {
"function_call" -> trailing.add(item.optString("call_id"))
"reasoning" -> {}
else -> break
}
}

val orphanOutputs = outputCounts.keys - callIds
val duplicated = outputCounts.filter { (id, n) -> n > 1 && id in callIds }.keys
val unanswered = callIds - outputCounts.keys - trailing
if (orphanOutputs.isEmpty() && duplicated.isEmpty() && unanswered.isEmpty()) {
return Result(items, emptyList(), emptyList(), emptyList())
}

val out = JSONArray()
val seenOutputs = HashSet<String>()
val droppedOrphans = mutableListOf<String>()
val droppedDuplicates = mutableListOf<String>()
val placeholders = mutableListOf<String>()
val pending = mutableListOf<JSONObject>()
fun flush() {
for (p in pending) out.put(p)
pending.clear()
}
for (i in 0 until items.length()) {
val item = items.optJSONObject(i)
if (item == null) {
out.put(items.get(i))
continue
}
val type = item.optString("type")
if (type == "function_call_output") {
val id = item.optString("call_id")
if (id in orphanOutputs) { droppedOrphans += id; continue }
if (!seenOutputs.add(id)) { droppedDuplicates += id; continue }
}
// A real output closes the call run just as a placeholder does, so
// pending placeholders go BEFORE it: one contiguous output block.
if (type != "function_call") flush()
out.put(item)
if (type == "function_call") {
val id = item.optString("call_id")
if (id in unanswered) {
placeholders += id
pending += JSONObject().apply {
put("type", "function_call_output")
put("call_id", id)
put("output", PLACEHOLDER_OUTPUT)
}
}
}
}
flush()
return Result(out, droppedOrphans, droppedDuplicates, placeholders)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import com.openminis.app.data.db.CompactMarkerEntity
import com.openminis.app.data.model.AgentContentPart
import com.openminis.app.data.model.LLMMessage
import com.openminis.app.logging.AppLogger
import com.openminis.app.provider.ToolPairing

/**
* What is actually sent to the model from the conversation history: the compaction summary spliced
Expand Down Expand Up @@ -244,13 +245,16 @@ internal object OutgoingHistory {
* invalid on several providers.
*/
fun dropOrphanedToolParts(history: List<LLMMessage>): List<LLMMessage> {
// [T-android-responses-tool-id-normalize] Paired on ToolPairing.key, not the raw id:
// the Responses path stores "call|fc" ids and a history can hold both forms for
// one call, which raw ids called orphans (ported from OpenMinis 1.14).
val toolUseIds = HashSet<String>()
val toolResultIds = HashSet<String>()
for (msg in history) {
for (part in msg.contentParts) {
when (part) {
is AgentContentPart.ToolUse -> toolUseIds.add(part.id)
is AgentContentPart.ToolResult -> toolResultIds.add(part.id)
is AgentContentPart.ToolUse -> toolUseIds.add(ToolPairing.key(part.id))
is AgentContentPart.ToolResult -> toolResultIds.add(ToolPairing.key(part.id))
else -> {}
}
}
Expand All @@ -271,7 +275,7 @@ internal object OutgoingHistory {
val last = history.lastOrNull()
if (last != null && last.role == LLMMessage.Role.ASSISTANT) {
for (part in last.contentParts) {
if (part is AgentContentPart.ToolUse) orphanedUses.remove(part.id)
if (part is AgentContentPart.ToolUse) orphanedUses.remove(ToolPairing.key(part.id))
}
}

Expand All @@ -288,7 +292,7 @@ internal object OutgoingHistory {
val cleaned = ArrayList<LLMMessage>(history.size)
for (msg in history) {
val kept = msg.contentParts.filter { part ->
if (part is AgentContentPart.ToolResult) !orphanedResults.contains(part.id) else true
if (part is AgentContentPart.ToolResult) !orphanedResults.contains(ToolPairing.key(part.id)) else true
}
// Only drop the message when it HAD parts and lost them all. A
// plain text message legitimately carries no contentParts and must
Expand All @@ -300,7 +304,7 @@ internal object OutgoingHistory {
// placeholder results it never got, so the pair is complete.
if (msg.role != LLMMessage.Role.ASSISTANT) continue
val unanswered = kept.filterIsInstance<AgentContentPart.ToolUse>()
.filter { orphanedUses.contains(it.id) }
.filter { orphanedUses.contains(ToolPairing.key(it.id)) }
if (unanswered.isNotEmpty()) {
cleaned.add(
LLMMessage(
Expand Down
Loading
Loading