From 29e509fa9d0b5e627e4819cf8557abd815aad257 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Wed, 30 Sep 2026 06:29:32 +0800 Subject: [PATCH 01/10] feat(tools): repair flattened proxy calls and the memory review argument alias A device export of tool-call failures (117 records) showed three call shapes that a deterministic repair can resolve before execution, so the routing layer now covers them. - A proxy call whose model lost the envelope writes the target name at the end of the params text instead of declaring it as a sibling parameter. Both observed shapes are recovered, and only when the remaining text is one unambiguous argument object. That object is delimited by string-aware brace counting, so a command containing braces is still recovered, and a blank tool_name is replaced rather than left beside the new one. - The memory review tool takes one opaque argument object, so a model can reach for the old_text/new_text pair that diff-style editors use. `content` is the only key the tool reads, and an edit sent as `new_text` was applied as an empty replacement, which deleted the matched text instead of rewriting it. The alias is renamed for the actions that really read `content`, on both model-visible entry points of the tool. - A terminal target with a repeated separator is the same typo as a doubled one, so the existing name rule now collapses any run of separators instead of matching one exact string. The nested argument object is held to the same strict parse as a proxy one, so duplicate keys are refused rather than silently collapsed. New rules are logged and labelled in the repair panel in Chinese and English, and docs/agent/tool-call-repairs.md documents both. Verified with :app:testDebugUnitTest (364 suites, 2693 tests, 0 failures) after two independent read-only audits. Replaying the export repaired 47/47 flattened proxy calls and 16/16 `new_text` calls. --- .../operit/core/tools/ToolCallRepairRouter.kt | 128 +++++++++-- .../operit/core/tools/ToolCallRepairRules.kt | 54 ++++- .../toolbox/screens/logcat/ToolRepairPanel.kt | 2 + app/src/main/res/values-en/strings.xml | 4 +- app/src/main/res/values/strings.xml | 4 +- .../core/tools/ToolCallRepairRouterTest.kt | 203 +++++++++++++++++- docs/agent/tool-call-repairs.md | 13 +- 7 files changed, 384 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouter.kt b/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouter.kt index 811f04f844..e3dc170687 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouter.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouter.kt @@ -1,6 +1,8 @@ package com.ai.assistance.operit.core.tools +import com.ai.assistance.operit.data.model.AITool import com.ai.assistance.operit.data.model.ToolInvocation +import com.ai.assistance.operit.data.model.ToolParameter import org.json.JSONTokener import kotlinx.serialization.json.* @@ -10,6 +12,8 @@ internal object ToolCallRepairRouter { const val TERMINAL_SEPARATOR = "terminal_single_separator" const val TERMINAL_TIMEOUT = "terminal_timeout_alias" const val REDUNDANT_PACKAGE_NAME = "redundant_proxy_package_name" + const val PROXY_FLATTENED_TOOL_NAME = "proxy_flattened_tool_name" + const val MEMORY_ARGUMENT_ALIAS = "memory_argument_alias" data class Repair( val original: ToolInvocation, @@ -26,19 +30,12 @@ internal object ToolCallRepairRouter { fun route(original: ToolInvocation): Repair? { val tool = original.tool val isProxy = tool.name == "proxy" || tool.name == "package_proxy" - val initial = if (isProxy) { - if (tool.parameters.map { it.name }.distinct().size != tool.parameters.size) return null - val name = tool.parameters.singleOrNull { it.name == "tool_name" }?.value?.trim() - ?.takeIf { it.isNotEmpty() } ?: return null - if (tool.name == "package_proxy" && ':' !in name) return null - val raw = tool.parameters.singleOrNull { it.name == "params" }?.value ?: return null - val args = parseArguments(raw) ?: return null - ToolRepairCall(name, args, tool.parameters) - } else { - ToolRepairCall(tool.name, tool.parameters.map { ToolRepairArgument(it.name, JsonPrimitive(it.value)) }) - } + val decoded = if (isProxy) (decodeProxy(tool) ?: return null) else null + val initial = decoded?.call ?: ToolRepairCall(tool.name, + tool.parameters.map { ToolRepairArgument(it.name, JsonPrimitive(it.value)) }) var current = initial val applied = mutableListOf() + decoded?.leadingRule?.let { applied += it } for (rule in ToolCallRepairRules.ordered) { val next = rule.apply(current) ?: continue if (next == current) continue @@ -59,15 +56,114 @@ internal object ToolCallRepairRouter { }) } else { tool.copy(name = current.targetName, parameters = current.arguments.map { - com.ai.assistance.operit.data.model.ToolParameter(it.name, it.value.jsonPrimitive.content) + ToolParameter(it.name, it.value.jsonPrimitive.content) }) } - return Repair(original, original.copy(tool = routed), initial.targetName, current.targetName, + // The log names the call as it arrived: the target the model declared, or the proxy tool + // itself when the model declared no target at all. + return Repair(original, original.copy(tool = routed), + decoded?.reportedName ?: initial.targetName, current.targetName, current.arguments.map { it.name }, applied, initial.arguments.map { it.name }) } - /** Reject duplicate keys before a repair can rewrite an argument object. */ - private fun parseArguments(raw: String): List? = runCatching { + private data class DecodedProxy( + val call: ToolRepairCall, + val leadingRule: String?, + val reportedName: String, + ) + + /** + * A model that loses the proxy envelope writes the target name at the end of the params text + * instead of declaring it as a sibling parameter. Recovering it is only safe when the rest of + * the text is still exactly one argument object, so [splitFlattenedProxyParams] decides that. + */ + private fun decodeProxy(tool: AITool): DecodedProxy? { + if (tool.parameters.map { it.name }.distinct().size != tool.parameters.size) return null + val declared = tool.parameters.singleOrNull { it.name == "tool_name" }?.value?.trim() + ?.takeIf { it.isNotEmpty() } + val supplied = tool.parameters.singleOrNull { it.name == "params" }?.value ?: return null + val flattened = if (declared == null) splitFlattenedProxyParams(supplied) else null + val name = declared ?: flattened?.toolName ?: return null + if (tool.name == "package_proxy" && ':' !in name) return null + val raw = flattened?.params ?: supplied + val args = parseArguments(raw) ?: return null + // A blank tool_name counts as undeclared, so the recovered one replaces that parameter + // instead of sitting next to it: two tool_name parameters are rejected outright. + val parameters = if (flattened == null) tool.parameters else + listOf(ToolParameter("tool_name", name)) + tool.parameters + .filterNot { it.name == "tool_name" } + .map { parameter -> + if (parameter.name == "params") ToolParameter("params", raw) else parameter + } + return DecodedProxy(ToolRepairCall(name, args, parameters), + if (flattened == null) null else PROXY_FLATTENED_TOOL_NAME, + declared ?: tool.name) + } + + private data class FlattenedProxyParams(val toolName: String, val params: String) + + /** `{...}, {"tool_name": "x"}` — the model kept a separator but dropped the sibling key. */ + private val flattenedTrailerWithObject = Regex( + """^\s*,\s*\{\s*"tool_name"\s*:\s*"([^"\\]*)"\s*\}\s*\z""") + + /** `{...}, "tool_name": "x"}` — the same flattening with one closing brace left behind. */ + private val flattenedTrailerWithStrayBrace = Regex( + """^\s*,\s*"tool_name"\s*:\s*"([^"\\]*)"\s*\}\s*\z""") + + /** + * Both accepted shapes must leave exactly one argument object, and that object must not carry a + * tool_name of its own, so the recovered pair is never a choice between two candidates. The + * leading object is delimited by string-aware brace counting, so arguments that themselves + * contain braces are still recovered. A shape that fails any check is left alone, not guessed. + */ + private fun splitFlattenedProxyParams(raw: String): FlattenedProxyParams? { + val end = endOfLeadingObject(raw) ?: return null + val params = raw.substring(0, end) + if (!hasNoOwnToolNameKey(params)) return null + val name = (flattenedTrailerWithObject.matchEntire(raw.substring(end)) + ?: flattenedTrailerWithStrayBrace.matchEntire(raw.substring(end))) + ?.groupValues?.get(1) + return name?.trim()?.takeIf { it.isNotEmpty() }?.let { FlattenedProxyParams(it, params) } + } + + /** Index just past the balanced object that starts the text, ignoring braces inside strings. */ + private fun endOfLeadingObject(raw: String): Int? { + if (raw.firstOrNull() != '{') return null + var depth = 0 + var inString = false + var escaped = false + raw.forEachIndexed { index, character -> + if (inString) { + when { + escaped -> escaped = false + character == '\\' -> escaped = true + character == '"' -> inString = false + } + } else when (character) { + '"' -> inString = true + '{' -> depth++ + '}' -> { + depth-- + if (depth == 0) return index + 1 + } + } + } + return null + } + + /** False unless the text is one object that is proven not to carry a tool_name of its own. */ + private fun hasNoOwnToolNameKey(raw: String): Boolean = + (runCatching { Json.parseToJsonElement(raw) as? JsonObject }.getOrNull()) + ?.containsKey("tool_name") == false + + private fun parseArguments(raw: String): List? = + parseStrictJsonObject(raw)?.map { ToolRepairArgument(it.key, it.value) } + + /** + * Reject duplicate keys, bare literals and raw controls before a repair can rewrite an argument + * object. Shared so a nested argument object is held to the same standard as a proxy one. + */ + internal fun parseStrictJsonObject(raw: String): JsonObject? = runCatching { // kotlinx accepts bare literals and raw controls in strings; validate those explicitly. var inString = false var escaped = false @@ -89,7 +185,7 @@ internal object ToolCallRepairRouter { val input = JSONTokener(raw) consumeUniqueJsonValue(input) require(input.nextClean() == '\u0000') - parsed.map { ToolRepairArgument(it.key, it.value) } + parsed }.getOrNull() private fun consumeUniqueJsonValue(input: JSONTokener) { diff --git a/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRules.kt b/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRules.kt index f48f3d61fa..5f29ad9331 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRules.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRules.kt @@ -2,6 +2,7 @@ package com.ai.assistance.operit.core.tools import com.ai.assistance.operit.data.model.ToolParameter import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive internal data class ToolRepairArgument(val name: String, val value: JsonElement) @@ -19,10 +20,13 @@ internal data class ToolCallRepairRule( ) internal object ToolCallRepairRules { + private val repeatedTerminalSeparator = Regex("""^super_admin::+terminal$""") + // Name normalization precedes canonical-name rules. One pass, no retry loop. val ordered = listOf( ToolCallRepairRule(ToolCallRepairRouter.TERMINAL_SEPARATOR) { call -> - if (call.targetName == "super_admin::terminal") + // Any run of separators is the same typo; every other name stays untouched. + if (repeatedTerminalSeparator.matches(call.targetName)) call.copy(targetName = "super_admin:terminal") else null }, ToolCallRepairRule(ToolCallRepairRouter.REDUNDANT_PACKAGE_NAME) { call -> @@ -55,5 +59,53 @@ internal object ToolCallRepairRules { call.arguments.filterNot { it.name == "timeout" } }) }, + ToolCallRepairRule(ToolCallRepairRouter.MEMORY_ARGUMENT_ALIAS) { call -> + if (call.targetName !in MEMORY_REVIEW_TOOLS) return@ToolCallRepairRule null + val action = (call.arguments.singleOrNull { it.name == MEMORY_ACTION_FIELD }?.value + as? JsonPrimitive)?.content + if (action == null || action !in MEMORY_CONTENT_ACTIONS) return@ToolCallRepairRule null + val field = call.arguments.singleOrNull { it.name == MEMORY_ARGUMENT_FIELD } + ?: return@ToolCallRepairRule null + val raw = (field.value as? JsonPrimitive)?.takeIf { it.isString }?.content + ?: return@ToolCallRepairRule null + val renamed = renameMemoryArgumentAlias(raw) ?: return@ToolCallRepairRule null + call.copy(arguments = call.arguments.map { + if (it.name == MEMORY_ARGUMENT_FIELD) it.copy(value = JsonPrimitive(renamed)) else it + }) + }, ) } + +private const val MEMORY_ACTION_FIELD = "action" +private const val MEMORY_ARGUMENT_FIELD = "arguments" +private const val MEMORY_ALIASED_KEY = "new_text" +private const val MEMORY_CANONICAL_KEY = "content" + +/** + * Both model-visible entry points carry the same contract: `action` plus one opaque `arguments` + * object, executed by the same [com.ai.assistance.operit.api.chat.library.MemoryLearningActions]. + * `memory_review` is deliberately absent: its parameters are flat, with no `arguments` object. + */ +private val MEMORY_REVIEW_TOOLS = setOf("memory_learning_action", "learning_manage") + +/** Only these actions read the argument object's `content`, so only these gain from the rename. */ +private val MEMORY_CONTENT_ACTIONS = setOf( + "memory_change", "skill_create", "skill_write", "skill_patch", "skill_remove_file", +) + +/** + * The review tool takes one opaque argument object, so a model can reach for the old_text/new_text + * pair that diff-style editors use. `content` is the only key the tool reads, and an edit sent as + * `new_text` is applied as an empty replacement, which deletes the matched text instead of + * rewriting it. Sending both keys is ambiguous, so nothing is rewritten in that case, and the same + * strict object parse the proxy path uses rejects duplicate keys instead of collapsing them. + */ +private fun renameMemoryArgumentAlias(raw: String): String? { + val target = ToolCallRepairRouter.parseStrictJsonObject(raw) ?: return null + if (target.containsKey(MEMORY_CANONICAL_KEY)) return null + val alias = target[MEMORY_ALIASED_KEY] as? JsonPrimitive ?: return null + if (!alias.isString) return null + return JsonObject(target.map { (key, value) -> + if (key == MEMORY_ALIASED_KEY) MEMORY_CANONICAL_KEY to value else key to value + }.toMap()).toString() +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/ToolRepairPanel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/ToolRepairPanel.kt index 5d1540caac..5ffeed5588 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/ToolRepairPanel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/ToolRepairPanel.kt @@ -90,6 +90,8 @@ internal fun ToolRepairPanel(modifier: Modifier = Modifier) { ToolCallRepairRouter.TERMINAL_SEPARATOR -> R.string.tool_repairs_separator ToolCallRepairRouter.TERMINAL_TIMEOUT -> R.string.tool_repairs_timeout ToolCallRepairRouter.REDUNDANT_PACKAGE_NAME -> R.string.tool_repairs_package + ToolCallRepairRouter.PROXY_FLATTENED_TOOL_NAME -> R.string.tool_repairs_flattened_proxy + ToolCallRepairRouter.MEMORY_ARGUMENT_ALIAS -> R.string.tool_repairs_memory_argument else -> null } Text(if (label == null) rule else stringResource(label)) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index a847b07775..c0bac5805c 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -8835,9 +8835,11 @@ %1$d repair records No tool call repairs yet Line range redirected to the partial file reader - Corrected the terminal tool name to use a single colon + Corrected repeated colons in the terminal tool name Normalized terminal timeout to timeoutMs Removed redundant package_name matching the target package + Recovered the tool_name that was folded into the params text + Renamed the memory tool\'s new_text argument to content Invalid record format; tap to view the raw record Refresh %1$d records · %2$d tools diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5191539bdb..c022bcd3a2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8811,9 +8811,11 @@ 共 %1$d 条修复记录 暂无工具调用修复记录 按行号读取,自动转为分段读取工具 - 将终端工具名中的双冒号修正为单冒号 + 将终端工具名中重复的冒号修正为单冒号 将终端 timeout 参数统一为 timeoutMs 移除与目标工具包一致的重复 package_name + 从 params 文本中还原出被并入的 tool_name + 将记忆工具误写的 new_text 参数改为 content 记录格式无效,点击查看原始记录 刷新 %1$d 条记录 · %2$d 个工具 diff --git a/app/src/test/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouterTest.kt b/app/src/test/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouterTest.kt index 38201f3345..e96229bc54 100644 --- a/app/src/test/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouterTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouterTest.kt @@ -137,13 +137,210 @@ class ToolCallRepairRouterTest { } @Test fun separatorRuleIsAnExactAllowlist() { - assertEquals("super_admin:terminal", - ToolCallRepairRouter.route(call("super_admin::terminal"))!!.invocation.tool.name) - for (name in listOf("super_admin::shell", "another::terminal", "super_admin:::terminal")) { + for (name in listOf("super_admin::terminal", "super_admin:::terminal")) { + assertEquals(name, "super_admin:terminal", + ToolCallRepairRouter.route(call(name))!!.invocation.tool.name) + } + for (name in listOf("super_admin::shell", "another::terminal", + "super_admin::terminal_wait", "super_admin:terminal")) { assertNull(ToolCallRepairRouter.route(call(name))) } } + @Test fun flattenedProxyParamsRestoreTheToolNameAndItsArguments() { + val inner = """{"command":"echo hi","timeoutMs":150000}""" + for (params in listOf( + """$inner, {"tool_name": "super_admin:terminal"}""", + """$inner, "tool_name": "super_admin:terminal"}""", + )) { + val repair = ToolCallRepairRouter.route(call("package_proxy", "params" to params))!! + assertEquals(listOf(ToolCallRepairRouter.PROXY_FLATTENED_TOOL_NAME), repair.rules) + assertEquals("package_proxy", repair.invocation.tool.name) + assertEquals("super_admin:terminal", + repair.invocation.tool.parameters.single { it.name == "tool_name" }.value) + assertEquals(2, repair.invocation.tool.parameters.size) + val args = JSONObject(repair.invocation.tool.parameters.single { it.name == "params" }.value) + assertEquals("echo hi", args.getString("command")) + assertEquals(150000, args.getInt("timeoutMs")) + assertFalse(args.has("tool_name")) + assertNull(ToolCallRepairRouter.route(repair.invocation)) + val log = JSONObject(ToolCallRepairLogger.entry(repair, 123)) + // No target was declared, so the log names the proxy tool the model actually called. + assertEquals("package_proxy", log.getString("originalToolName")) + assertEquals("super_admin:terminal", log.getString("targetToolName")) + assertEquals(ToolCallRepairRouter.PROXY_FLATTENED_TOOL_NAME, log.getString("rule")) + assertFalse(log.toString().contains("echo hi")) + } + } + + @Test fun flattenedProxyShapeIsRejectedWhenItWouldNeedAGuess() { + val inner = """{"command":"echo hi"}""" + for (params in listOf( + // Two candidate names: the first object would keep a tool_name of its own. + """$inner, {"tool_name": "super_admin:terminal"}, {"tool_name": "read_file"}""", + // Missing separator, unseparated inside one object, and a brace inside the arguments. + """$inner "tool_name": "super_admin:terminal"}""", + """{"command":"echo hi","tool_name":"super_admin:terminal"}""", + // An empty name cannot address anything. + """$inner, {"tool_name": ""}""", + )) { + assertNull(params, ToolCallRepairRouter.route(call("package_proxy", "params" to params))) + } + // package_proxy still requires an unqualified name to be qualified by the recovered target. + assertNull(ToolCallRepairRouter.route(call("package_proxy", + "params" to """$inner, {"tool_name": "read_file"}"""))) + assertEquals("read_file", ToolCallRepairRouter.route(call("proxy", + "params" to """$inner, {"tool_name": "read_file"}"""))!!.targetToolName) + } + + @Test fun flattenedProxyParamsTolerateBracesInsideArguments() { + // Braces inside the command text are common; they must not hide the shape from the rule. + val params = """{"command":"echo ${'$'}{VAR}; awk '{print}'","timeoutMs":1}, "tool_name": "super_admin:terminal"}""" + val repair = ToolCallRepairRouter.route(call("package_proxy", "params" to params))!! + assertEquals(listOf(ToolCallRepairRouter.PROXY_FLATTENED_TOOL_NAME), repair.rules) + assertEquals("super_admin:terminal", + repair.invocation.tool.parameters.single { it.name == "tool_name" }.value) + val args = JSONObject(repair.invocation.tool.parameters.single { it.name == "params" }.value) + assertTrue(args.getString("command").contains("{VAR}")) + assertTrue(args.getString("command").contains("'{print}'")) + assertEquals(1, args.getInt("timeoutMs")) + // An unclosed object stays unrepaired rather than being repaired with a truncated argument. + assertNull(ToolCallRepairRouter.route(call("package_proxy", + "params" to """{"command":"echo hi, "tool_name": "super_admin:terminal"}"""))) + } + + @Test fun flattenedProxyRepairLeavesTheEnvelopeTheProxyParserRequires() { + // The real gate is ToolRegistration.parseProxyInvocation, which needs an Android context and + // is not reachable from a JVM test; this pins exactly the shape that gate accepts: one + // non-blank tool_name, one params, a qualified target for package_proxy, and params that is + // a JSON object. + val repair = ToolCallRepairRouter.route(call("package_proxy", + "params" to """{"command":"echo hi"}, {"tool_name": "super_admin:terminal"}"""))!! + val parameters = repair.invocation.tool.parameters + assertEquals(2, parameters.size) + assertEquals(setOf("tool_name", "params"), parameters.map { it.name }.toSet()) + val name = parameters.single { it.name == "tool_name" }.value + assertTrue(name.isNotBlank()) + assertTrue(name.contains(':')) + assertEquals(name, name.trim()) + assertTrue(parameters.single { it.name == "params" }.value.startsWith("{")) + } + + @Test fun flattenedProxyTrailerToleratesWhitespaceOnBothShapes() { + for (params in listOf( + """{"command":"echo hi"} , "tool_name": "super_admin:terminal"}""", + """{"command":"echo hi"}, "tool_name": "super_admin:terminal" }""", + """{"command":"echo hi"}, {"tool_name": "super_admin:terminal" }""", + )) { + assertEquals(params, "super_admin:terminal", + ToolCallRepairRouter.route(call("package_proxy", "params" to params))!!.targetToolName) + } + // The recovered name is trimmed; an escaped quote in it cannot be resolved and is left alone. + assertEquals("super_admin:terminal", ToolCallRepairRouter.route(call("package_proxy", + "params" to """{"command":"echo hi"}, "tool_name": " super_admin:terminal "}"""))!!.targetToolName) + assertNull(ToolCallRepairRouter.route(call("package_proxy", + "params" to """{"command":"echo hi"}, "tool_name": "super_admin:termi\"nal"}"""))) + } + + @Test fun flattenedProxyParamsNeverOverrideADeclaredToolName() { + val original = call("package_proxy", "tool_name" to "super_admin:terminal", + "params" to """{"command":"echo hi"}, {"tool_name": "read_file"}""") + assertNull(ToolCallRepairRouter.route(original)) + } + + @Test fun flattenedProxyReplacesABlankToolNameInsteadOfDuplicatingIt() { + for (blank in listOf("", " ")) { + val repair = ToolCallRepairRouter.route(call("package_proxy", "tool_name" to blank, + "params" to """{"command":"echo hi"}, {"tool_name": "super_admin:terminal"}"""))!! + assertEquals(listOf(ToolCallRepairRouter.PROXY_FLATTENED_TOOL_NAME), repair.rules) + assertEquals("super_admin:terminal", repair.targetToolName) + assertEquals(listOf("tool_name", "params"), + repair.invocation.tool.parameters.map { it.name }) + assertEquals("super_admin:terminal", + repair.invocation.tool.parameters.single { it.name == "tool_name" }.value) + val args = JSONObject(repair.invocation.tool.parameters.single { it.name == "params" }.value) + assertEquals("echo hi", args.getString("command")) + } + // A blank tool_name with nothing folded into params is a different fault and stays put. + assertNull(ToolCallRepairRouter.route(call("package_proxy", "tool_name" to "", + "params" to """{"command":"echo hi"}"""))) + } + + @Test fun memoryArgumentAliasRenamesNewTextToContent() { + for (action in listOf("memory_change", "skill_create", "skill_write", "skill_patch", + "skill_remove_file")) { + val repair = ToolCallRepairRouter.route(call("memory_learning_action", + "action" to action, + "arguments" to """{"path":"SKILL.md","old_text":"x","new_text":"第一行\n第二行","force":true,"index":123456789012345678901234567890}"""))!! + assertEquals(action, listOf(ToolCallRepairRouter.MEMORY_ARGUMENT_ALIAS), repair.rules) + assertEquals("memory_learning_action", repair.invocation.tool.name) + assertEquals(action, + repair.invocation.tool.parameters.single { it.name == "action" }.value) + val text = repair.invocation.tool.parameters.single { it.name == "arguments" }.value + val args = JSONObject(text) + // Every other value keeps its exact form: escaped text, booleans, large integers. + assertEquals("第一行\n第二行", args.getString("content")) + assertEquals("x", args.getString("old_text")) + assertTrue(args.getBoolean("force")) + assertTrue(text.contains("123456789012345678901234567890")) + assertFalse(args.has("new_text")) + assertNull(ToolCallRepairRouter.route(repair.invocation)) + } + } + + @Test fun memoryArgumentAliasIsRejectedWhenAmbiguousOrAbsent() { + // Both keys present, so renaming would have to choose between two stated values. + assertNull(ToolCallRepairRouter.route(call("memory_learning_action", "action" to "memory_change", + "arguments" to """{"old_text":"a","content":"b","new_text":"c"}"""))) + // Already canonical, not an object, a non-string alias, and a different tool. + assertNull(ToolCallRepairRouter.route(call("memory_learning_action", "action" to "memory_change", + "arguments" to """{"old_text":"a","content":"b"}"""))) + assertNull(ToolCallRepairRouter.route(call("memory_learning_action", "action" to "memory_change", + "arguments" to "new_text=1"))) + assertNull(ToolCallRepairRouter.route(call("memory_learning_action", "action" to "memory_change", + "arguments" to """{"new_text":7}"""))) + assertNull(ToolCallRepairRouter.route(call("memory_learning_action", "action" to "memory_change"))) + assertNull(ToolCallRepairRouter.route(call("other_tool", + "arguments" to """{"new_text":"c"}"""))) + // A duplicate key is collapsed by a lenient parser, which would silently drop a stated edit. + assertNull(ToolCallRepairRouter.route(call("memory_learning_action", "action" to "memory_change", + "arguments" to """{"new_text":"a","new_text":"b"}"""))) + assertNull(ToolCallRepairRouter.route(call("memory_learning_action", "action" to "memory_change", + "arguments" to """{"path":"a","path":"b","new_text":"c"}"""))) + } + + @Test fun memoryArgumentAliasOnlyAppliesToActionsThatReadContent() { + for (action in listOf("memory_read", "history", "skill_read", "skill_list", "skill_delete", "")) { + assertNull(action, ToolCallRepairRouter.route(call("memory_learning_action", + "action" to action, "arguments" to """{"old_text":"x","new_text":"y"}"""))) + } + // A repeated action parameter is left alone rather than resolved to one of the two values. + assertNull(ToolCallRepairRouter.route(call("memory_learning_action", + "action" to "memory_change", "action" to "history", + "arguments" to """{"old_text":"x","new_text":"y"}"""))) + // The same contract is also exposed as learning_manage; memory_review has flat parameters. + for (tool in listOf("memory_learning_action", "learning_manage")) { + assertEquals(tool, listOf(ToolCallRepairRouter.MEMORY_ARGUMENT_ALIAS), + ToolCallRepairRouter.route(call(tool, "action" to "memory_change", + "arguments" to """{"old_text":"x","new_text":"y"}"""))!!.rules) + } + assertNull(ToolCallRepairRouter.route(call("memory_review", "action" to "audit", + "arguments" to """{"new_text":"y"}"""))) + } + + @Test fun memoryArgumentAliasAlsoAppliesThroughTheProxyEnvelope() { + // The review tool is not a packaged tool, so only the CLI proxy can carry it. + val repair = ToolCallRepairRouter.route(call("proxy", + "tool_name" to "memory_learning_action", + "params" to """{"action":"memory_change","arguments":"{\"old_text\":\"x\",\"new_text\":\"y\"}"}"""))!! + assertEquals(listOf(ToolCallRepairRouter.MEMORY_ARGUMENT_ALIAS), repair.rules) + assertEquals("proxy", repair.invocation.tool.name) + val params = JSONObject(repair.invocation.tool.parameters.single { it.name == "params" }.value) + val inner = JSONObject(params.getString("arguments")) + assertEquals("y", inner.getString("content")) + assertFalse(inner.has("new_text")) + } + @Test fun brokenOrDuplicateArgumentJsonCannotBeRewritten() { for (raw in listOf( """{"timeout":3000,"timeout":4000}""", diff --git a/docs/agent/tool-call-repairs.md b/docs/agent/tool-call-repairs.md index a1cc68f9f2..b80027a3e2 100644 --- a/docs/agent/tool-call-repairs.md +++ b/docs/agent/tool-call-repairs.md @@ -3,14 +3,23 @@ 聊天工具批次在权限检查之前应用确定的修复规则: - `read_file` 出现 `start_line` 或 `end_line` 时,改为 `read_file_part`。 -- `super_admin::terminal` 精确修正为 `super_admin:terminal`。 +- `super_admin` 命名空间下重复冒号的 `terminal`(如 `super_admin::terminal`、 + `super_admin:::terminal`)精确修正为 `super_admin:terminal`;其他名字不动。 - 仅对 `super_admin:terminal`,将有效整数毫秒 `timeout` 改名为 `timeoutMs`。 两者值相同时移除重复别名,冲突时不改;支持范围为 3000 到 Int.MAX_VALUE 毫秒。 - `proxy` / `package_proxy` 外层的 `package_name` 与目标包名前缀完全相同时移除重复字段。 +- 代理调用没有声明 `tool_name`(缺失或空白)时,如果 `params` 末尾并入了目标名, + 且只可能是 `<对象>, {"tool_name":"x"}` 或 `<对象>, "tool_name":"x"}` 这两种形态之一, + 则还原出工具名与参数对象。参数对象的边界按字符串感知的花括号配对判定(命令里含 `{`、`}` + 不影响),并且必须是一个不含同名键的合法对象,否则不改。 +- `memory_learning_action` / `learning_manage` 的 `arguments` 里出现 `new_text`、没有 `content`,且 action + 属于读取 `content` 的 `memory_change` / `skill_create` / `skill_write` / `skill_patch` / + `skill_remove_file` 时,将 `new_text` 改名为 `content`。 - 支持直接调用和代理调用,保留代理外壳;`package_proxy` 仍只接受带包名的工具。 - 保留参数值、调用 ID、原始响应与调用位置;不补全缺失参数、不修正路径。 代理参数需要改名时重新编码 JSON,保留值及类型;只改工具名时保留原始 JSON 文本。 -- 格式无效、重复的外层参数或 JSON 任意层级重复键不自动修复。 +- 格式无效、重复的外层参数或 JSON 任意层级重复键不自动修复;嵌套的 `arguments` + 文本与代理 `params` 共用同一套严格解析,重复键会拒绝修复而不是静默折叠。 新增规则在 `ToolCallRepairRules.ordered` 注册唯一 ID 和纯转换函数,未命中返回 null。 规则按顺序只执行一遍,可组合(例如先修正工具名,再改超时参数名),不循环重试。 From c5d3cda55b2355fa6d5dfe9b0ef62e09aa28d61d Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Wed, 30 Sep 2026 08:45:44 +0800 Subject: [PATCH 02/10] fix(chat): keep one folded reply process across compaction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机反馈的三个体验问题一起修。 - 压缩把一次回复切成两个折叠块。运行中的自动总结写在它报告的那次运行里,运行紧接着 继续,现在两段之间只有压缩总结时视为同一个回复过程:折叠块合一、用时相加,总结分隔 线仍留在块外可见。 同类漏洞一并修掉:subagent 的任务/事件卡片落库时 sender 是 user,本属 AI 过程,却和 人的插话一样把合并判掉。现在卡片与总结一样不切断折叠,人的消息、工具代发的消息仍然 切断。两条渲染路径(持久化 metadata / 内存回退)的成员判定也对齐:被总结挤出间隔的 卡片留在折叠块外可见,不再一条路藏进块里、一条路留在块外。 - 压缩重置计时器。自动续写且本轮已在计时时不再重置起点。 - 用时文案只有秒,长任务读不出来。改为 x时x分x秒 / h:mm:ss。 验证: - 聚焦单测全绿(TranscriptStructureTest 11、ResponseProcessMessagesTest 14 等);把合并规则 退回旧逻辑时新增用例会失败,确认测试有效。 - :app:assembleDebug 成功,adb install -r 覆盖安装到真机验证,数据未动。 - 独立只读审计结论 CLEAN,无 P0/P1/P2。 --- .../core/MessageProcessingDelegate.kt | 5 +- .../services/core/TranscriptStructure.kt | 107 +++++++++++++++--- .../common/markdown/ResponseActivityHeader.kt | 8 +- .../ui/features/chat/components/ChatArea.kt | 23 ++-- .../chat/components/LiveResponseTimer.kt | 7 +- .../components/ResponseProcessMessages.kt | 71 +++++++++--- app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../services/core/TranscriptStructureTest.kt | 88 ++++++++++++++ .../chat/components/LiveResponseTimerTest.kt | 5 +- .../components/ResponseProcessMessagesTest.kt | 94 +++++++++++++++ 11 files changed, 374 insertions(+), 40 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/services/core/MessageProcessingDelegate.kt b/app/src/main/java/com/ai/assistance/operit/services/core/MessageProcessingDelegate.kt index baca6ea8ca..8e4270d768 100644 --- a/app/src/main/java/com/ai/assistance/operit/services/core/MessageProcessingDelegate.kt +++ b/app/src/main/java/com/ai/assistance/operit/services/core/MessageProcessingDelegate.kt @@ -809,7 +809,10 @@ class MessageProcessingDelegate( chatRuntime.streamingAiMessage = null chatRuntime.toolBoundarySnapshot = null chatRuntime.steeringBoundarySnapshot = null - chatRuntime.runStartedElapsed = android.os.SystemClock.elapsedRealtime() + // 压缩后的自动续写是同一个任务的后半段:重启计时会让用户看到长任务被清零。 + if (!isAutoContinuation || chatRuntime.runStartedElapsed <= 0L) { + chatRuntime.runStartedElapsed = android.os.SystemClock.elapsedRealtime() + } chatRuntime.isLoading.value = true chatRuntime.currentTurnOptions = turnOptions chatRuntime.inputInbox = TurnInputInbox() diff --git a/app/src/main/java/com/ai/assistance/operit/services/core/TranscriptStructure.kt b/app/src/main/java/com/ai/assistance/operit/services/core/TranscriptStructure.kt index 20fefc3809..442b500a4f 100644 --- a/app/src/main/java/com/ai/assistance/operit/services/core/TranscriptStructure.kt +++ b/app/src/main/java/com/ai/assistance/operit/services/core/TranscriptStructure.kt @@ -10,6 +10,39 @@ internal data class TranscriptTurn( val durationMs: Long, ) +/** Rows written by the app to report the transcript rather than to take part in a turn. */ +internal const val TRANSCRIPT_SUMMARY_SENDER: String = "summary" + +/** Agent input cards report the work of the run they sit in, so they stay inside its process. */ +internal fun isAgentInputCard(displayModeName: String): Boolean = + ChatMessageDisplayMode.entries + .firstOrNull { it.name == displayModeName } + ?.isCollaborationEvent == true + +/** + * True when the rows between two turns belong to the reply process itself. An automatic summary is + * written inside the run it reports and the run resumes right after it; an agent input card is + * that run reporting its own subagent work. Either way those turns are one reply process instead of + * two. A row that asks for a new answer — the owner's own turn or one the app delivered for them — + * still ends the process. + */ +internal fun isContinuedAcrossSummaries( + betweenSenders: List, + betweenDisplayModeNames: List, +): Boolean = + betweenSenders.isNotEmpty() && + betweenSenders.size == betweenDisplayModeNames.size && + betweenSenders.indices.all { index -> + betweenSenders[index] == TRANSCRIPT_SUMMARY_SENDER || + isAgentInputCard(betweenDisplayModeNames[index]) + } + +private data class TurnSegment( + val turn: TranscriptTurn, + val firstIndex: Int, + val lastIndex: Int, +) + /** Presentation structure is independent of which bodies happen to be in memory. */ internal class TranscriptStructure(val rows: List) { fun retainedTimestamps(topLevelTimestamps: Set, loadedTimestamps: List): Set = @@ -23,41 +56,87 @@ internal class TranscriptStructure(val rows: List) { val topLevelRows: List init { - val completed = mutableListOf() + val segments = mutableListOf() val pending = mutableListOf() + var pendingFirstIndex = -1 var pendingKey: Long? = null - for (row in rows) { - val collaboration = row.displayMode == ChatMessageDisplayMode.COLLABORATION_EVENT.name || - row.displayMode == ChatMessageDisplayMode.COLLABORATION_TASK.name + rows.forEachIndexed { index, row -> + val collaboration = isAgentInputCard(row.displayMode) if (row.sender != "ai") { - if (collaboration) pending += row - else if (pendingKey == null) pending.clear() - continue + if (collaboration) { + if (pending.isEmpty()) pendingFirstIndex = index + pending += row + } else if (pendingKey == null) { + pending.clear() + pendingFirstIndex = -1 + } + return@forEachIndexed + } + if (pendingKey != null && pendingKey != row.sentAt) { + pending.clear() + pendingFirstIndex = -1 } - if (pendingKey != null && pendingKey != row.sentAt) pending.clear() pendingKey = row.sentAt if (row.sentAt > 0 && row.displayMode == ChatMessageDisplayMode.ASSISTANT_INTERMEDIATE.name) { + if (pending.isEmpty()) pendingFirstIndex = index pending += row } else { if (pending.isNotEmpty() && row.sentAt > 0 && row.completedAt > 0 && row.displayMode == ChatMessageDisplayMode.NORMAL.name ) { - completed += TranscriptTurn( - row.sentAt, row.timestamp, - (pending.map { it.timestamp } + row.timestamp).toSet(), - row.waitDurationMs + row.outputDurationMs, + segments += TurnSegment( + turn = TranscriptTurn( + row.sentAt, row.timestamp, + (pending.map { it.timestamp } + row.timestamp).toSet(), + row.waitDurationMs + row.outputDurationMs, + ), + firstIndex = if (pendingFirstIndex >= 0) pendingFirstIndex else index, + lastIndex = index, ) } pending.clear() + pendingFirstIndex = -1 pendingKey = null } } - turns = completed + turns = mergeContinuedSegments(segments) turnByTimestamp = buildMap { - completed.forEach { turn -> turn.memberTimestamps.forEach { put(it, turn) } } + turns.forEach { turn -> turn.memberTimestamps.forEach { put(it, turn) } } } topLevelRows = rows.filter { row -> turnByTimestamp[row.timestamp]?.let { it.finalTimestamp == row.timestamp } ?: true } } + + /** + * A run that compaction cut in half is resumed by an automatic continuation that brings no user + * message of its own, so the halves stay one fold and their elapsed time adds up. + */ + private fun mergeContinuedSegments(segments: List): List { + val merged = mutableListOf() + var previousLastIndex = -1 + segments.forEach { segment -> + val previous = merged.lastOrNull() + val between = previousLastIndex + 1 until segment.firstIndex + if (previous != null && + isContinuedAcrossSummaries( + between.map { rows[it].sender }, + between.map { rows[it].displayMode }, + ) + ) { + merged[merged.lastIndex] = previous.continuedBy(segment.turn) + } else { + merged += segment.turn + } + previousLastIndex = segment.lastIndex + } + return merged + } } + +private fun TranscriptTurn.continuedBy(next: TranscriptTurn): TranscriptTurn = + copy( + finalTimestamp = next.finalTimestamp, + memberTimestamps = memberTimestamps + next.memberTimestamps, + durationMs = durationMs + next.durationMs, + ) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/common/markdown/ResponseActivityHeader.kt b/app/src/main/java/com/ai/assistance/operit/ui/common/markdown/ResponseActivityHeader.kt index c5dafd962b..737901cb0f 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/common/markdown/ResponseActivityHeader.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/common/markdown/ResponseActivityHeader.kt @@ -39,7 +39,13 @@ internal fun ResponseActivityHeader( val title = when { durationMs <= 0L -> stringResource(R.string.collapse_process_title) seconds < 60 -> stringResource(R.string.collapse_process_duration_seconds, seconds) - else -> stringResource(R.string.collapse_process_duration_minutes, seconds / 60, seconds % 60) + seconds < 3600 -> stringResource(R.string.collapse_process_duration_minutes, seconds / 60, seconds % 60) + else -> stringResource( + R.string.collapse_process_duration_hours, + seconds / 3600, + (seconds % 3600) / 60, + seconds % 60, + ) } val action = stringResource( if (expanded) R.string.collapse_process_hide else R.string.collapse_process_show diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatArea.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatArea.kt index 44d804f1c2..f93301565b 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatArea.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatArea.kt @@ -1,5 +1,6 @@ package com.ai.assistance.operit.ui.features.chat.components +import android.content.Context import android.widget.Toast import androidx.compose.animation.core.RepeatMode import androidx.compose.animation.core.StartOffset @@ -1195,16 +1196,24 @@ private fun hasDisplayableMessageTimestamp(message: ChatMessage): Boolean { message.completedAt > 0L } -private fun formatCompactDuration(durationMs: Long): String { +/** Sub-minute spans stay compact; longer ones read as minutes and hours instead of a huge seconds count. */ +private fun formatCompactDuration(context: Context, durationMs: Long): String { if (durationMs <= 0L) return "0ms" - return if (durationMs >= 1000L) { - if (durationMs >= 10_000L) { + if (durationMs < 1000L) return "${durationMs}ms" + if (durationMs < 60_000L) { + return if (durationMs >= 10_000L) { String.format(Locale.getDefault(), "%.0fs", durationMs / 1000f) } else { String.format(Locale.getDefault(), "%.1fs", durationMs / 1000f) } + } + val totalSeconds = durationMs / 1000L + val minutes = totalSeconds / 60L + val seconds = totalSeconds % 60L + return if (minutes < 60L) { + context.getString(R.string.duration_compact_minutes, minutes, seconds) } else { - "${durationMs}ms" + context.getString(R.string.duration_compact_hours, minutes / 60L, minutes % 60L, seconds) } } @@ -1242,9 +1251,9 @@ internal fun MessageFooterBar( val totalDuration = (message.waitDurationMs + message.outputDurationMs).coerceAtLeast(0L) context.getString( R.string.chat_message_timing_stats_compact, - formatCompactDuration(totalDuration), - formatCompactDuration(message.waitDurationMs), - formatCompactDuration(message.outputDurationMs), + formatCompactDuration(context, totalDuration), + formatCompactDuration(context, message.waitDurationMs), + formatCompactDuration(context, message.outputDurationMs), ) } val messageTimeSummary = diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/LiveResponseTimer.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/LiveResponseTimer.kt index a45b0db7da..0ebc6f810d 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/LiveResponseTimer.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/LiveResponseTimer.kt @@ -11,7 +11,12 @@ import java.util.Locale internal fun formatResponseElapsed(elapsedMs: Long): String { val seconds = elapsedMs.coerceAtLeast(0L) / 1000 - return String.format(Locale.ROOT, "%02d:%02d", seconds / 60, seconds % 60) + val hours = seconds / 3600 + return if (hours > 0) { + String.format(Locale.ROOT, "%d:%02d:%02d", hours, (seconds % 3600) / 60, seconds % 60) + } else { + String.format(Locale.ROOT, "%02d:%02d", seconds / 60, seconds % 60) + } } @Composable diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ResponseProcessMessages.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ResponseProcessMessages.kt index c40c1ea496..6508b05e82 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ResponseProcessMessages.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ResponseProcessMessages.kt @@ -19,6 +19,7 @@ import com.ai.assistance.operit.data.model.ChatMessage import com.ai.assistance.operit.data.model.ChatMessageDisplayMode import com.ai.assistance.operit.data.model.ChatMessageProcessMetadata import com.ai.assistance.operit.data.preferences.DisplayPreferencesManager +import com.ai.assistance.operit.services.core.isContinuedAcrossSummaries import com.ai.assistance.operit.ui.common.markdown.LocalResponseProcessExpanded import com.ai.assistance.operit.ui.common.markdown.ResponseActivityHeader @@ -30,9 +31,15 @@ internal data class ResponseProcessGroup( val headerIndex: Int = firstIndex, ) +/** One reply process and the rows it wrote, before compaction halves are merged back together. */ +private data class ResponseProcessSegment( + val group: ResponseProcessGroup, + val members: List, +) + /** Agent input cards are part of the AI process; human steering stays outside the fold. */ internal fun responseProcessGroups(messages: List): Map { - val result = mutableMapOf() + val completed = mutableListOf() val pending = mutableListOf() val leadingCards = mutableListOf() messages.forEachIndexed { index, message -> @@ -57,25 +64,59 @@ internal fun responseProcessGroups(messages: List): Map 0L && message.contentStream == null ) { - val group = ResponseProcessGroup( - key = message.sentAt, - firstIndex = leadingCards.firstOrNull() ?: pending.first(), - finalIndex = index, - durationMs = message.waitDurationMs + message.outputDurationMs, - headerIndex = pending.firstOrNull() ?: index, - ) - (group.firstIndex..index).forEach { member -> - if (messages[member].sender == "ai" || - messages[member].displayMode.isCollaborationEvent - ) { - result[member] = group - } - } + val group = + ResponseProcessGroup( + key = message.sentAt, + firstIndex = leadingCards.firstOrNull() ?: pending.first(), + finalIndex = index, + durationMs = message.waitDurationMs + message.outputDurationMs, + headerIndex = pending.firstOrNull() ?: index, + ) + // Members are the rows this run wrote. A row that only sits in the gap between two + // halves — a card the summary pushed out, or the summary itself — belongs to + // neither half, so it stays visible outside the fold. + completed += + ResponseProcessSegment( + group, + (group.firstIndex..group.finalIndex).filter { member -> + messages[member].sender == "ai" || + messages[member].displayMode.isCollaborationEvent + }, + ) } pending.clear() leadingCards.clear() } } + // Compaction is written inside the run it reports and the run resumes right after it; an agent + // input card is that same run reporting its subagent work. Both keep one fold and add up its + // time instead of cutting the reply process in half there. + val merged = mutableListOf() + completed.forEach { segment -> + val group = segment.group + val previous = merged.lastOrNull() + val between = previous?.let { (it.group.finalIndex + 1 until group.firstIndex).toList() } + val continued = + between != null && + isContinuedAcrossSummaries( + between.map { messages[it].sender }, + between.map { messages[it].displayMode.name }, + ) + if (previous != null && continued) { + merged[merged.lastIndex] = + ResponseProcessSegment( + previous.group.copy( + finalIndex = group.finalIndex, + durationMs = previous.group.durationMs + group.durationMs, + ), + previous.members + segment.members, + ) + } else { + merged += segment + } + } + val result = mutableMapOf() + merged.forEach { segment -> segment.members.forEach { result[it] = segment.group } } return result } diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index c0bac5805c..316471806c 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -8821,6 +8821,9 @@ Hide response activity Worked for %1$ds Worked for %1$dm %2$ds + Worked for %1$dh %2$dm %3$ds + %1$dm %2$ds + %1$dh %2$dm %3$ds Tool call issues App logs Records execution failures and undeclared parameters in AI chats and subagents, even when tools succeed. Keeps original parameters. Cancellations and permission denials are excluded. Recording starts with this version. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c022bcd3a2..78902c54a7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8797,6 +8797,9 @@ 收起回复过程 用时 %1$d秒 用时 %1$d分钟 %2$d秒 + 用时 %1$d时%2$d分%3$d秒 + %1$d分%2$d秒 + %1$d时%2$d分%3$d秒 工具调用问题 应用日志 记录 AI 对话与子代理的执行失败和未声明参数,即使工具返回成功。保留原始参数;取消和拒绝授权不计入。从此版本开始记录。 diff --git a/app/src/test/java/com/ai/assistance/operit/services/core/TranscriptStructureTest.kt b/app/src/test/java/com/ai/assistance/operit/services/core/TranscriptStructureTest.kt index 90dbf29af9..3cadb1181f 100644 --- a/app/src/test/java/com/ai/assistance/operit/services/core/TranscriptStructureTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/services/core/TranscriptStructureTest.kt @@ -49,4 +49,92 @@ class TranscriptStructureTest { val different = listOf(row(1), row(2, mode = "NORMAL", sentAt = 20, completedAt = 500)) assertEquals(different, TranscriptStructure(different).topLevelRows) } + + @Test fun compactionSummaryKeepsOneTurnSoItsProcessStaysWhole() { + val rows = listOf( + row(1), row(2, mode = "NORMAL", completedAt = 500), + row(3, "summary", "NORMAL"), + row(4, sentAt = 20), row(5, mode = "NORMAL", sentAt = 20, completedAt = 600), + ) + val structure = TranscriptStructure(rows) + assertEquals(1, structure.turns.size) + assertEquals(setOf(1L, 2L, 4L, 5L), structure.turns.single().memberTimestamps) + assertEquals(5L, structure.turnByTimestamp.getValue(1).finalTimestamp) + // The halves are one process, so the elapsed time adds up instead of restarting. + assertEquals(100L, structure.turns.single().durationMs) + // The divider reporting the compression stays a row of its own. + assertEquals(listOf(3L, 5L), structure.topLevelRows.map { it.timestamp }) + // Loading that divider keeps the whole process it belongs to. + assertEquals(setOf(1L, 2L, 4L, 5L), structure.retainedTimestamps(setOf(5L), listOf(1L, 2L, 4L, 5L))) + } + + @Test fun aHumanMessageStillEndsThePreviousProcess() { + val rows = listOf( + row(1), row(2, mode = "NORMAL", completedAt = 500), + row(3, "summary", "NORMAL"), row(4, "user", "NORMAL"), + row(5, sentAt = 20), row(6, mode = "NORMAL", sentAt = 20, completedAt = 600), + ) + val structure = TranscriptStructure(rows) + assertEquals(listOf(2L, 6L), structure.turns.map { it.finalTimestamp }) + } + + @Test fun anAgentCardBesideTheSummaryKeepsOneProcess() { + val rows = listOf( + row(1), row(2, mode = "NORMAL", completedAt = 500), + row(3, "user", "COLLABORATION_EVENT"), + row(4, "summary", "NORMAL"), + row(5, sentAt = 20), row(6, mode = "NORMAL", sentAt = 20, completedAt = 600), + ) + val structure = TranscriptStructure(rows) + assertEquals(1, structure.turns.size) + assertEquals(setOf(1L, 2L, 5L, 6L), structure.turns.single().memberTimestamps) + assertEquals(6L, structure.turnByTimestamp.getValue(1).finalTimestamp) + assertEquals(100L, structure.turns.single().durationMs) + // The card the summary pushed into the gap, and the divider itself, stay visible outside. + assertEquals(listOf(3L, 4L, 6L), structure.topLevelRows.map { it.timestamp }) + } + + @Test fun anAgentCardAfterTheSummaryStaysInsideTheProcess() { + val rows = listOf( + row(1), row(2, mode = "NORMAL", completedAt = 500), + row(3, "summary", "NORMAL"), + row(4, "user", "COLLABORATION_EVENT"), + row(5, sentAt = 20), row(6, mode = "NORMAL", sentAt = 20, completedAt = 600), + ) + val structure = TranscriptStructure(rows) + assertEquals(1, structure.turns.size) + assertEquals(setOf(1L, 2L, 4L, 5L, 6L), structure.turns.single().memberTimestamps) + assertEquals(6L, structure.turnByTimestamp.getValue(1).finalTimestamp) + assertEquals(listOf(3L, 6L), structure.topLevelRows.map { it.timestamp }) + } + + @Test fun anAgentCardAloneNeverGluesTwoTurnsTogether() { + val rows = listOf( + row(1), row(2, mode = "NORMAL", completedAt = 500), + row(3, "user", "COLLABORATION_EVENT"), + row(4, sentAt = 20), row(5, mode = "NORMAL", sentAt = 20, completedAt = 600), + ) + assertEquals(2, TranscriptStructure(rows).turns.size) + } + + @Test fun aHiddenPlaceholderStillEndsTheProcess() { + val rows = listOf( + row(1), row(2, mode = "NORMAL", completedAt = 500), + row(3, "user", "HIDDEN_PLACEHOLDER"), + row(4, "summary", "NORMAL"), + row(5, sentAt = 20), row(6, mode = "NORMAL", sentAt = 20, completedAt = 600), + ) + assertEquals(listOf(2L, 6L), TranscriptStructure(rows).turns.map { it.finalTimestamp }) + } + + @Test fun aTurnTheAppDeliveredForTheOwnerStillEndsTheProcess() { + val rows = listOf( + row(1), row(2, mode = "NORMAL", completedAt = 500), + row(3, "user", "TOOL_DELIVERED"), + row(4, "summary", "NORMAL"), + row(5, sentAt = 20), row(6, mode = "NORMAL", sentAt = 20, completedAt = 600), + ) + val structure = TranscriptStructure(rows) + assertEquals(listOf(2L, 6L), structure.turns.map { it.finalTimestamp }) + } } diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/chat/components/LiveResponseTimerTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/chat/components/LiveResponseTimerTest.kt index 549eb56c7b..297d195dbe 100644 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/chat/components/LiveResponseTimerTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/chat/components/LiveResponseTimerTest.kt @@ -8,6 +8,9 @@ class LiveResponseTimerTest { assertEquals("00:00", formatResponseElapsed(-1)) assertEquals("00:59", formatResponseElapsed(59_999)) assertEquals("01:00", formatResponseElapsed(60_000)) - assertEquals("60:00", formatResponseElapsed(3_600_000)) + // A long task keeps counting instead of piling minutes past the hour. + assertEquals("59:59", formatResponseElapsed(3_599_999)) + assertEquals("1:00:00", formatResponseElapsed(3_600_000)) + assertEquals("2:05:06", formatResponseElapsed(7_506_000)) } } diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/chat/components/ResponseProcessMessagesTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/chat/components/ResponseProcessMessagesTest.kt index 02b20563f4..149f988886 100644 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/chat/components/ResponseProcessMessagesTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/chat/components/ResponseProcessMessagesTest.kt @@ -100,6 +100,100 @@ class ResponseProcessMessagesTest { assertTrue(responseProcessGroups(messages.take(2)).isEmpty()) } + @Test fun compactionSummaryKeepsTheReplyProcessFoldedTogether() { + val messages = listOf( + intermediate(10), final(10), + summary(), + intermediate(20), final(20), + ) + val groups = responseProcessGroups(messages) + assertEquals(setOf(0, 1, 3, 4), groups.keys) + val group = groups.getValue(0) + assertEquals(1, groups.values.toSet().size) + assertEquals(0, group.firstIndex) + assertEquals(4, group.finalIndex) + assertEquals(10L, group.key) + // The halves are one process, so the elapsed time adds up instead of restarting. + assertEquals(6000L, group.durationMs) + // The divider reporting the compression stays visible outside the fold. + assertNull(groups[2]) + } + + @Test fun repeatedCompactionsStayOneProcess() { + val messages = listOf( + intermediate(10), final(10), + summary(), + intermediate(20), final(20), + summary(), + intermediate(30), final(30), + ) + val group = responseProcessGroups(messages).getValue(0) + assertEquals(0, group.firstIndex) + assertEquals(7, group.finalIndex) + assertEquals(9000L, group.durationMs) + } + + @Test fun aHumanMessageStillSeparatesTwoRepliesEvenAfterASummary() { + val messages = listOf( + intermediate(10), final(10), + summary(), + ChatMessage(sender = "user", content = "next"), + intermediate(20), final(20), + ) + val groups = responseProcessGroups(messages) + assertEquals(10L, groups.getValue(0).key) + assertEquals(20L, groups.getValue(4).key) + assertEquals(2, groups.values.toSet().size) + } + + @Test fun anAgentCardBesideTheSummaryKeepsTheReplyProcessFoldedTogether() { + val messages = listOf( + intermediate(10), final(10), + ChatMessage(sender = "user", content = "mail", displayMode = ChatMessageDisplayMode.COLLABORATION_EVENT), + summary(), + intermediate(20), final(20), + ) + val groups = responseProcessGroups(messages) + val group = groups.getValue(0) + assertEquals(setOf(0, 1, 4, 5), groups.keys) + assertEquals(1, groups.values.toSet().size) + assertEquals(0, group.firstIndex) + assertEquals(5, group.finalIndex) + assertEquals(6000L, group.durationMs) + // The card the summary pushed into the gap, and the divider itself, stay outside the fold. + assertNull(groups[2]) + assertNull(groups[3]) + } + + @Test fun anAgentCardAfterTheSummaryFoldsInsideTheProcess() { + val messages = listOf( + intermediate(10), final(10), + summary(), + ChatMessage(sender = "user", content = "mail", displayMode = ChatMessageDisplayMode.COLLABORATION_EVENT), + intermediate(20), final(20), + ) + val groups = responseProcessGroups(messages) + val group = groups.getValue(0) + assertEquals(setOf(0, 1, 3, 4, 5), groups.keys) + assertEquals(1, groups.values.toSet().size) + assertEquals(0, group.firstIndex) + assertEquals(5, group.finalIndex) + assertEquals(6000L, group.durationMs) + assertNull(groups[2]) + } + + @Test fun anAgentCardAloneNeverGluesTwoRepliesTogether() { + val messages = listOf( + intermediate(10), final(10), + ChatMessage(sender = "user", content = "mail", displayMode = ChatMessageDisplayMode.COLLABORATION_EVENT), + intermediate(20), final(20), + ) + val groups = responseProcessGroups(messages) + assertEquals(2, groups.values.toSet().size) + } + + private fun summary() = ChatMessage(sender = "summary", content = "compressed") + private fun intermediate(sentAt: Long) = ChatMessage( sender = "ai", content = "work", sentAt = sentAt, displayMode = ChatMessageDisplayMode.ASSISTANT_INTERMEDIATE, From 9b89c60027cd52128bb0793a99834ea239d2c4d3 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Wed, 30 Sep 2026 09:47:10 +0800 Subject: [PATCH 03/10] fix(chat): number the message locator by the rows it shows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 真机反馈:聊天页右侧的消息定位条里,只要对话出现过 subagent 返回的消息,序号就会跳号, 出现 1、2、4、6 这种(被吞掉的号属于隐藏的协作卡片)。 定位列表的序号原本用的是数据库下标,而 DAO 算下标时会数进 collaboration 卡片,UI 又把 这些卡片过滤掉不显示,于是留下空档。现在过滤后按显示顺序重新编号,序号连续;表头 「当前定位:第 x / y 条」的分母也改成显示行数,与行号同口径。 同时修掉同一处的连带问题:搜索状态下条目编号自成一套,而当前行高亮与自动滚动仍在拿 它跟整段对话的位置比较,导致搜索时高亮/落点错位。两处改为按时间戳定位,并保留原行为 ——阅读位置落在隐藏卡片上时,仍高亮离它最近的可见行;并列时取紧随其后的那一条,与 locatorCurrentVisiblePosition 的既有约定一致。 验证: - 聚焦单测全绿(LocatorVisibleEntriesTest 7 项,其中新增 3 项:卡片不占号、时间戳并列 取后一条、没有候选则不高亮)。 - :app:assembleDebug 成功。 - 独立只读审计结论 CLEAN,无 P0/P1/P2;尚未做真机验证。 --- .../chat/components/ChatScrollNavigator.kt | 26 ++++++++++----- .../chat/components/LocatorVisibleEntries.kt | 29 ++++++++++++---- .../components/LocatorVisibleEntriesTest.kt | 33 +++++++++++++++++-- 3 files changed, 71 insertions(+), 17 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatScrollNavigator.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatScrollNavigator.kt index d474d94725..9d19e0fef8 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatScrollNavigator.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatScrollNavigator.kt @@ -590,8 +590,10 @@ internal fun ChatMessageLocatorDialog( val visibleLocatorEntries = locatorVisibleEntries(locatorEntries) val currentVisiblePosition = locatorCurrentVisiblePosition(locatorEntries, currentMessageTimestamp) - val currentMessageIndex = - visibleLocatorEntries.getOrNull(currentVisiblePosition)?.messageIndex ?: -1 + // The row that stands for the reading position; a hidden row maps to the closest visible one. + val currentVisibleEntry = visibleLocatorEntries.getOrNull(currentVisiblePosition) + val currentMessageIndex = currentVisibleEntry?.messageIndex ?: -1 + val referenceTimestamp = currentVisibleEntry?.timestamp ?: currentMessageTimestamp val initialIndex = currentVisiblePosition .takeIf { it >= 0 } @@ -672,6 +674,15 @@ internal fun ChatMessageLocatorDialog( } } + // Search results are numbered within their own list, so both paths locate the reading position + // by timestamp instead of comparing numbers that only one of them shares. + val highlightedTimestamp = + if (normalizedSearchQuery.isBlank()) { + referenceTimestamp + } else { + locatorNearestTimestamp(filteredEntries.map { it.preview.timestamp }, referenceTimestamp) + } + LaunchedEffect(normalizedSearchQuery, filteredEntries.size, currentMessageIndex, dialogIsLoading) { if (dialogIsLoading || filteredEntries.isEmpty()) { return@LaunchedEffect @@ -679,14 +690,13 @@ internal fun ChatMessageLocatorDialog( val targetListIndex = if (normalizedSearchQuery.isBlank()) { - filteredEntries.indexOfFirst { it.index == currentMessageIndex } + filteredEntries.indexOfFirst { it.preview.timestamp == referenceTimestamp } .takeIf { it >= 0 } ?.let { (it - 2).coerceAtLeast(0) } ?: 0 } else { - filteredEntries.indices.minByOrNull { entryIndex -> - abs(filteredEntries[entryIndex].index - currentMessageIndex) - } ?: 0 + filteredEntries.indexOfFirst { it.preview.timestamp == highlightedTimestamp } + .takeIf { it >= 0 } ?: 0 } listState.scrollToItem(targetListIndex) } @@ -725,7 +735,7 @@ internal fun ChatMessageLocatorDialog( stringResource( R.string.chat_message_locator_current, (currentMessageIndex + 1).coerceAtLeast(0), - locatorEntries.size, + visibleLocatorEntries.size, ), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -868,7 +878,7 @@ internal fun ChatMessageLocatorDialog( preview = entry.preview, isFavorite = favoriteOverrides[entry.preview.timestamp] ?: entry.preview.isFavorite, - isCurrent = entry.index == currentMessageIndex, + isCurrent = entry.preview.timestamp == highlightedTimestamp, maxMessageLength = maxMessageLength, searchQuery = searchQuery, onToggleFavorite = { diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/LocatorVisibleEntries.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/LocatorVisibleEntries.kt index 8bc3609a02..a552eb7048 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/LocatorVisibleEntries.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/LocatorVisibleEntries.kt @@ -1,14 +1,31 @@ package com.ai.assistance.operit.ui.features.chat.components import com.ai.assistance.operit.data.model.ChatMessageLocatorPreview +import kotlin.math.abs +/** + * Rows the transcript hides must not hold a position of their own: the locator numbers the rows it + * actually lists, one after another and with no gaps. Agent input cards stay out of that list, so + * they cannot leave a hole in the numbering the way database indices do. + */ internal fun locatorVisibleEntries( entries: List, ): List = - entries.mapIndexedNotNull { index, entry -> - if (entry.resolvedDisplayMode.isCollaborationEvent) null - else entry.copy(messageIndex = entry.messageIndex ?: index) - } + entries.filterNot { it.resolvedDisplayMode.isCollaborationEvent } + .mapIndexed { index, entry -> entry.copy(messageIndex = index) } + +/** + * The listed row that stands for a reading position. Search results are numbered within their own + * list, so the position is matched by timestamp instead of by a number only one list shares. Ties + * prefer the later row, the way a hidden row already prefers the reply that follows it. + */ +internal fun locatorNearestTimestamp( + timestamps: List, + referenceTimestamp: Long, +): Long? = + timestamps.minWithOrNull( + compareBy { abs(it - referenceTimestamp) }.thenByDescending { it }, + ) /** Hidden collaboration rows locate the closest ordinary row; ties prefer the following reply. */ internal fun locatorCurrentVisiblePosition( @@ -22,11 +39,11 @@ internal fun locatorCurrentVisiblePosition( if (sourceIndex >= 0) { val nearest = entries.indices .filter { !entries[it].resolvedDisplayMode.isCollaborationEvent } - .minWithOrNull(compareBy { kotlin.math.abs(it - sourceIndex) }.thenByDescending { it }) + .minWithOrNull(compareBy { abs(it - sourceIndex) }.thenByDescending { it }) return visible.indexOfFirst { it.timestamp == nearest?.let { index -> entries[index].timestamp } } } // The visible anchor may have just been removed or not yet persisted in the preview snapshot. return visible.indices.minByOrNull { - kotlin.math.abs(visible[it].timestamp.toDouble() - timestamp.toDouble()) + abs(visible[it].timestamp.toDouble() - timestamp.toDouble()) } ?: -1 } diff --git a/app/src/test/java/com/ai/assistance/operit/ui/features/chat/components/LocatorVisibleEntriesTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/features/chat/components/LocatorVisibleEntriesTest.kt index 9aa2f19b88..cbff95f23c 100644 --- a/app/src/test/java/com/ai/assistance/operit/ui/features/chat/components/LocatorVisibleEntriesTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/ui/features/chat/components/LocatorVisibleEntriesTest.kt @@ -38,16 +38,43 @@ class LocatorVisibleEntriesTest { ) val visible = locatorVisibleEntries(entries) assertEquals(listOf("user", "ai", "user"), visible.map { it.sender }) - assertEquals(listOf(0, 3, 4), visible.map { it.messageIndex }) + assertEquals(listOf(0, 1, 2), visible.map { it.messageIndex }) } - @Test fun searchResultsPreserveDatabaseIndicesAndNormalPlaceholders() { + @Test fun agentCardsDoNotLeaveHolesInTheLocatorNumbers() { + val entries = listOf( + preview("NORMAL").copy(timestamp = 10, messageIndex = 0), + preview("NORMAL").copy(timestamp = 20, messageIndex = 1, sender = "ai"), + preview("COLLABORATION_EVENT").copy(timestamp = 30, messageIndex = 2), + preview("NORMAL").copy(timestamp = 40, messageIndex = 3, sender = "ai"), + preview("COLLABORATION_TASK").copy(timestamp = 50, messageIndex = 4), + preview("NORMAL").copy(timestamp = 60, messageIndex = 5, sender = "ai"), + ) + val visible = locatorVisibleEntries(entries) + // The rows that are listed stay 1, 2, 3, 4 instead of 1, 2, 4, 6. + assertEquals(listOf(0, 1, 2, 3), visible.map { it.messageIndex }) + assertEquals(listOf(10L, 20L, 40L, 60L), visible.map { it.timestamp }) + } + + @Test fun searchResultsNumberOnlyTheRowsTheyShow() { val entries = listOf( preview("COLLABORATION_EVENT").copy(messageIndex = 22), preview("HIDDEN_PLACEHOLDER").copy(messageIndex = 30), preview("future-mode").copy(messageIndex = 42), ) - assertEquals(listOf(30, 42), locatorVisibleEntries(entries).map { it.messageIndex }) + val visible = locatorVisibleEntries(entries) + assertEquals(listOf("HIDDEN_PLACEHOLDER", "future-mode"), visible.map { it.displayMode }) + assertEquals(listOf(0, 1), visible.map { it.messageIndex }) + } + + @Test fun nearestHitFallsBackToTheLaterRowOnATie() { + assertEquals(30L, locatorNearestTimestamp(listOf(10L, 30L), 20L)) + assertEquals(40L, locatorNearestTimestamp(listOf(40L, 30L, 10L), 35L)) + assertEquals(30L, locatorNearestTimestamp(listOf(10L, 30L), 31L)) + } + + @Test fun nearestHitWithoutACandidateHighlightsNothing() { + assertEquals(null, locatorNearestTimestamp(emptyList(), 20L)) } private fun preview(mode: String) = ChatMessageLocatorPreview( From 1372dcc14c5fc58d01a4921f768e0858d210d52d Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Wed, 7 Oct 2026 21:03:06 +0800 Subject: [PATCH 04/10] perf: reduce image decoding and request allocation overhead --- .../api/chat/llmprovider/DoubaoAIProvider.kt | 3 +- .../api/chat/llmprovider/KimiProvider.kt | 3 +- .../api/chat/llmprovider/NvidiaAIProvider.kt | 3 +- .../api/chat/llmprovider/OpenAIProvider.kt | 11 ++++--- .../llmprovider/OpenAIResponsesProvider.kt | 3 +- .../chat/llmprovider/OpenRouterProvider.kt | 3 +- .../api/chat/llmprovider/QwenAIProvider.kt | 3 +- .../collaboration/CollaborationCoordinator.kt | 12 +++++--- .../chat/components/ChatScrollNavigator.kt | 29 ++++++++++--------- .../operit/util/ImageDecodeSizing.kt | 12 ++++++++ .../operit/util/ImagePoolManager.kt | 23 ++++++++++++--- .../operit/util/ImageDecodeSizingTest.kt | 19 ++++++++++++ 12 files changed, 85 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/util/ImageDecodeSizing.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/util/ImageDecodeSizingTest.kt diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DoubaoAIProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DoubaoAIProvider.kt index 43b23ba71f..0b9d920158 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DoubaoAIProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/DoubaoAIProvider.kt @@ -51,8 +51,7 @@ class DoubaoAIProvider( preserveThinkInHistory: Boolean ): RequestBody { // 首先,调用父类的实现来获取一个标准的OpenAI格式的请求体JSON对象 - val baseRequestBodyJson = super.createRequestBodyInternal(context, chatHistory, modelParameters, stream, availableTools, preserveThinkInHistory) - val jsonObject = JSONObject(baseRequestBodyJson) + val jsonObject = super.createRequestBodyInternal(context, chatHistory, modelParameters, stream, availableTools, preserveThinkInHistory) // 豆包思考模式显式传参,避免依赖服务端默认值 val thinkingType = if (enableThinking) "enabled" else "disabled" diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt index cb27bc1f91..7a249ab346 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/KimiProvider.kt @@ -72,9 +72,8 @@ open class KimiProvider( } if (!enableThinking && configureThinking) { - val baseRequestBodyJson = + val jsonObject = super.createRequestBodyInternal(context, chatHistory, automaticReasoning.modelParameters, stream, availableTools, preserveThinkInHistory) - val jsonObject = JSONObject(baseRequestBodyJson) applyThinkingParams(jsonObject) return createJsonRequestBody(jsonObject.toString()) } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/NvidiaAIProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/NvidiaAIProvider.kt index d9c9ce4b60..e30bd93ef4 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/NvidiaAIProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/NvidiaAIProvider.kt @@ -58,7 +58,7 @@ class NvidiaAIProvider( availableTools: List?, preserveThinkInHistory: Boolean ): RequestBody { - val baseRequestBodyJson = super.createRequestBodyInternal( + val jsonObject = super.createRequestBodyInternal( context, chatHistory, modelParameters, @@ -66,7 +66,6 @@ class NvidiaAIProvider( availableTools, preserveThinkInHistory ) - val jsonObject = JSONObject(baseRequestBodyJson) // Explicit thinking toggle for NVIDIA template-based reasoning models. val chatTemplateKwargs = jsonObject.optJSONObject("chat_template_kwargs") ?: JSONObject() diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt index 13e54b4680..ac327046d7 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt @@ -675,7 +675,7 @@ open class OpenAIProvider( ): RequestBody { val automaticReasoningRequestParameters = consumeAutomaticReasoningSuppression(modelParameters) - val jsonString = + val requestJson = createRequestBodyInternal( context, chatHistory, @@ -688,9 +688,8 @@ open class OpenAIProvider( automaticReasoningRequestParameters.suppressAutomaticReasoning || !supportsOpenAiChatReasoningEffort() ) { - return createJsonRequestBody(jsonString) + return createJsonRequestBody(requestJson.toString()) } - val requestJson = JSONObject(jsonString) applyOpenAiChatReasoning(context, requestJson, enableThinking) return createJsonRequestBody(requestJson.toString()) } @@ -763,7 +762,7 @@ open class OpenAIProvider( } /** - * 内部方法,用于构建请求体的JSON字符串,以便子类可以重用和扩展。 + * Build a mutable request so providers can add options without copying the entire payload. */ protected fun createRequestBodyInternal( context: Context, @@ -772,7 +771,7 @@ open class OpenAIProvider( stream: Boolean = true, availableTools: List? = null, preserveThinkInHistory: Boolean = false - ): String { + ): JSONObject { val jsonObject = JSONObject() jsonObject.put("model", modelName) jsonObject.put("stream", stream) // 根据stream参数设置 @@ -867,7 +866,7 @@ open class OpenAIProvider( logRequestBodyForDebugging("AIService", "Request body: ") { finalRequestObject } - return finalRequestObject.toString() + return finalRequestObject } protected open fun comparableRoleForTurn(turn: PromptTurn): String { diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt index 11cf07df2c..96afa78170 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIResponsesProvider.kt @@ -64,7 +64,7 @@ open class OpenAIResponsesProvider( } else { ChatUtils.stripOpenAiResponsesReasoningMetaTurns(chatHistory) } - val baseRequestBodyJson = super.createRequestBodyInternal( + val jsonObject = super.createRequestBodyInternal( context, requestChatHistory, automaticReasoningRequestParameters.modelParameters, @@ -72,7 +72,6 @@ open class OpenAIResponsesProvider( availableTools, preserveThinkInHistory ) - val jsonObject = JSONObject(baseRequestBodyJson) if (!automaticReasoningRequestParameters.suppressAutomaticReasoning) { applyResponsesReasoningEffort( diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenRouterProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenRouterProvider.kt index 45a3d610ee..41f3f106a1 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenRouterProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenRouterProvider.kt @@ -59,7 +59,7 @@ open class OpenRouterProvider( availableTools: List?, preserveThinkInHistory: Boolean ): RequestBody { - val baseRequestBodyJson = super.createRequestBodyInternal( + val jsonObject = super.createRequestBodyInternal( context, chatHistory, modelParameters, @@ -67,7 +67,6 @@ open class OpenRouterProvider( availableTools, preserveThinkInHistory ) - val jsonObject = JSONObject(baseRequestBodyJson) applyOpenRouterReasoning( context = context, diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/QwenAIProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/QwenAIProvider.kt index 693c205609..8b88d09af0 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/QwenAIProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/QwenAIProvider.kt @@ -63,8 +63,7 @@ class QwenAIProvider( preserveThinkInHistory: Boolean ): RequestBody { // 首先,调用父类的实现来获取一个标准的OpenAI格式的请求体JSON对象 - val baseRequestBodyJson = super.createRequestBodyInternal(context, chatHistory, modelParameters, stream, availableTools, preserveThinkInHistory) - val jsonObject = JSONObject(baseRequestBodyJson) + val jsonObject = super.createRequestBodyInternal(context, chatHistory, modelParameters, stream, availableTools, preserveThinkInHistory) applyQwenReasoningSettings( context = context, diff --git a/app/src/main/java/com/ai/assistance/operit/core/agent/collaboration/CollaborationCoordinator.kt b/app/src/main/java/com/ai/assistance/operit/core/agent/collaboration/CollaborationCoordinator.kt index 0968394d39..a5aafb4f29 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/agent/collaboration/CollaborationCoordinator.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/agent/collaboration/CollaborationCoordinator.kt @@ -360,12 +360,16 @@ class CollaborationCoordinator private constructor(context: Context) { val text = (result as? SubagentTaskResult.Completed)?.outcome?.finalAssistantText ?.let { com.ai.assistance.operit.core.agent.SubagentResultExtractor.extract(it, "$path completed with an empty response") } ?: "$path: $status${errorText?.let { ": $it" }.orEmpty()}" - write(state.update(agent.copy( + val finished = state.update(agent.copy( status = status, lastError = errorText, finalAnswer = text.takeIf { status == CollaborationStatus.COMPLETED }, - ))) - if (agent.chatId in deleting || agent.parentPath == null) return - write(state.enqueue(root, AgentMessage( + )) + if (agent.chatId in deleting || agent.parentPath == null) { + write(finished) + return + } + // Commit the terminal state and its parent notification together. + write(finished.enqueue(root, AgentMessage( UUID.randomUUID().toString(), path, agent.parentPath, if (status == CollaborationStatus.COMPLETED) AgentMessageKind.FINAL_ANSWER else AgentMessageKind.STATUS, text.ifBlank { "$path completed with an empty response" }, diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatScrollNavigator.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatScrollNavigator.kt index 9d19e0fef8..262fe1ddaa 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatScrollNavigator.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/ChatScrollNavigator.kt @@ -587,9 +587,11 @@ internal fun ChatMessageLocatorDialog( onToggleFavoriteMessage: ((Long, Boolean) -> Unit)?, onJumpToMessage: (Long) -> Unit, ) { - val visibleLocatorEntries = locatorVisibleEntries(locatorEntries) + val visibleLocatorEntries = remember(locatorEntries) { locatorVisibleEntries(locatorEntries) } val currentVisiblePosition = - locatorCurrentVisiblePosition(locatorEntries, currentMessageTimestamp) + remember(locatorEntries, currentMessageTimestamp) { + locatorCurrentVisiblePosition(locatorEntries, currentMessageTimestamp) + } // The row that stands for the reading position; a hidden row maps to the closest visible one. val currentVisibleEntry = visibleLocatorEntries.getOrNull(currentVisiblePosition) val currentMessageIndex = currentVisibleEntry?.messageIndex ?: -1 @@ -612,7 +614,7 @@ internal fun ChatMessageLocatorDialog( if (normalizedSearchQuery.isBlank()) { visibleLocatorEntries } else { - locatorVisibleEntries(searchEntries) + remember(searchEntries) { locatorVisibleEntries(searchEntries) } } val dialogIsLoading = isLoading || isLoadingSearchEntries val dialogLoadFailed = @@ -622,22 +624,23 @@ internal fun ChatMessageLocatorDialog( searchLoadFailed } val indexedEntries = - activeLocatorEntries.mapIndexed { index, preview -> - ChatMessageLocatorEntry(index = preview.messageIndex ?: index, preview = preview) + remember(activeLocatorEntries) { + activeLocatorEntries.mapIndexed { index, preview -> + ChatMessageLocatorEntry(index = preview.messageIndex ?: index, preview = preview) + } } val filteredEntries = - if (dialogIsLoading) { - indexedEntries - } else { - indexedEntries.filter { entry -> - val isFavorite = + remember(indexedEntries, dialogIsLoading, favoritesOnly, favoriteOverrides) { + if (dialogIsLoading || !favoritesOnly) { + indexedEntries + } else { + indexedEntries.filter { entry -> favoriteOverrides[entry.preview.timestamp] ?: entry.preview.isFavorite - val matchesFavorite = !favoritesOnly || isFavorite - matchesFavorite + } } } val maxMessageLength = - remember(activeLocatorEntries) { + remember(activeLocatorEntries, hiddenPlaceholderText) { activeLocatorEntries.maxOfOrNull { messageContentLength(it, hiddenPlaceholderText) } ?.coerceAtLeast(1) ?: 1 } diff --git a/app/src/main/java/com/ai/assistance/operit/util/ImageDecodeSizing.kt b/app/src/main/java/com/ai/assistance/operit/util/ImageDecodeSizing.kt new file mode 100644 index 0000000000..3b3fed5437 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/util/ImageDecodeSizing.kt @@ -0,0 +1,12 @@ +package com.ai.assistance.operit.util + +/** Keep enough decoded pixels for the final resize, including after a 90-degree EXIF rotation. */ +internal fun imageDecodeSampleSize(width: Int, height: Int, maxLongEdge: Int): Int { + if (width <= 0 || height <= 0 || maxLongEdge <= 0) return 1 + val longEdge = maxOf(width, height) + var sample = 1 + while (sample <= Int.MAX_VALUE / 2 && longEdge / (sample * 2) >= maxLongEdge) { + sample *= 2 + } + return sample +} diff --git a/app/src/main/java/com/ai/assistance/operit/util/ImagePoolManager.kt b/app/src/main/java/com/ai/assistance/operit/util/ImagePoolManager.kt index 3d0960ce51..de6b60e44f 100644 --- a/app/src/main/java/com/ai/assistance/operit/util/ImagePoolManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/util/ImagePoolManager.kt @@ -128,7 +128,10 @@ object ImagePoolManager { return "error" } - val bitmap = decodeBitmapFromFile(file.absolutePath) ?: run { + val resolved = resolveOptions(options) + // Percentage scaling is relative to original pixels; keep that path unchanged. + val decodeLimit = resolved.maxLongEdge.takeIf { resolved.scalePercent == 100 } + val bitmap = decodeBitmapFromFile(file.absolutePath, decodeLimit) ?: run { AppLogger.e(TAG, "无法将文件解码为位图: $filePath") return "error" } @@ -156,7 +159,9 @@ object ImagePoolManager { return try { val normalized = normalizeBase64Input(base64, mimeType) val bytes = Base64.decode(normalized.base64, Base64.DEFAULT) - val bitmap = decodeBitmapFromBytes(bytes) ?: run { + val resolved = resolveOptions(options) + val decodeLimit = resolved.maxLongEdge.takeIf { resolved.scalePercent == 100 } + val bitmap = decodeBitmapFromBytes(bytes, decodeLimit) ?: run { AppLogger.e(TAG, "无法将 base64 解码为位图") return "error" } @@ -493,24 +498,34 @@ object ImagePoolManager { ) } - private fun decodeBitmapFromFile(filePath: String): Bitmap? { + private fun decodeBitmapFromFile(filePath: String, maxLongEdge: Int? = null): Bitmap? { val options = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 } return try { + if (maxLongEdge != null) { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(filePath, bounds) + options.inSampleSize = imageDecodeSampleSize(bounds.outWidth, bounds.outHeight, maxLongEdge) + } BitmapFactory.decodeFile(filePath, options) } catch (_: Throwable) { null } } - private fun decodeBitmapFromBytes(bytes: ByteArray): Bitmap? { + private fun decodeBitmapFromBytes(bytes: ByteArray, maxLongEdge: Int? = null): Bitmap? { val options = BitmapFactory.Options().apply { inPreferredConfig = Bitmap.Config.ARGB_8888 } return try { + if (maxLongEdge != null) { + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + options.inSampleSize = imageDecodeSampleSize(bounds.outWidth, bounds.outHeight, maxLongEdge) + } BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) } catch (_: Throwable) { null diff --git a/app/src/test/java/com/ai/assistance/operit/util/ImageDecodeSizingTest.kt b/app/src/test/java/com/ai/assistance/operit/util/ImageDecodeSizingTest.kt new file mode 100644 index 0000000000..7ed4509c35 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/util/ImageDecodeSizingTest.kt @@ -0,0 +1,19 @@ +package com.ai.assistance.operit.util + +import org.junit.Assert.assertEquals +import org.junit.Test + +class ImageDecodeSizingTest { + @Test fun `large photos are sampled without undershooting the output`() { + assertEquals(2, imageDecodeSampleSize(8000, 6000, 2048)) + assertEquals(2, imageDecodeSampleSize(6000, 8000, 2048)) + assertEquals(4, imageDecodeSampleSize(8192, 6144, 2048)) + assertEquals(1, imageDecodeSampleSize(4000, 3000, 2048)) + } + + @Test fun `small original and invalid bounds are not sampled`() { + assertEquals(1, imageDecodeSampleSize(1000, 500, 2048)) + assertEquals(1, imageDecodeSampleSize(-1, -1, 2048)) + assertEquals(1, imageDecodeSampleSize(8000, 6000, 0)) + } +} From 46c714ccd5b0df544577963ee1c5c3f41f16a8c1 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Wed, 7 Oct 2026 22:24:09 +0800 Subject: [PATCH 05/10] perf: shard collaboration storage and batch logging and rendering --- .../api/chat/llmprovider/ClaudeProvider.kt | 4 +- .../api/chat/llmprovider/GeminiProvider.kt | 4 +- .../api/chat/llmprovider/OpenAIProvider.kt | 4 +- .../agent/collaboration/CollaborationStore.kt | 148 +++++++++++++++--- .../data/backup/RawSnapshotBackupManager.kt | 17 +- .../common/markdown/RenderBatchCoordinator.kt | 16 +- .../common/markdown/StreamMarkdownRenderer.kt | 62 ++++---- .../screens/logcat/LogcatExportHelper.kt | 4 +- .../ai/assistance/operit/util/AppLogger.kt | 76 ++++++--- .../assistance/operit/util/BatchLogQueue.kt | 79 ++++++++++ app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../collaboration/CollaborationStoreTest.kt | 89 +++++++++++ .../markdown/RenderBatchCoordinatorTest.kt | 61 ++++++++ .../operit/util/BatchLogQueueTest.kt | 57 +++++++ docs/agent/runtime-performance.md | 48 ++++++ 16 files changed, 593 insertions(+), 78 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/util/BatchLogQueue.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/core/agent/collaboration/CollaborationStoreTest.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/util/BatchLogQueueTest.kt create mode 100644 docs/agent/runtime-performance.md diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt index fb75d533cd..5c94a643b5 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/ClaudeProvider.kt @@ -1508,7 +1508,7 @@ class ClaudeProvider( ) } - val builtRequestBody = createRequestBody( + val builtRequestBody = withContext(Dispatchers.IO) { createRequestBody( context, chatHistory, modelParameters, @@ -1516,7 +1516,7 @@ class ClaudeProvider( stream, availableTools, preserveThinkInHistory - ) + ) } attemptedThinkingFormat = builtRequestBody.thinkingFormat onTokensUpdated( tokenCacheManager.totalInputTokenCount, diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt index b8a5901135..2a79d4b65f 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/GeminiProvider.kt @@ -1430,7 +1430,9 @@ open class GeminiProvider( ) } - val requestBody = createRequestBody(context, chatHistory, modelParameters, enableThinking, availableTools, preserveThinkInHistory) + val requestBody = withContext(kotlinx.coroutines.Dispatchers.IO) { + createRequestBody(context, chatHistory, modelParameters, enableThinking, availableTools, preserveThinkInHistory) + } onTokensUpdated( tokenCacheManager.totalInputTokenCount, tokenCacheManager.cachedInputTokenCount, diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt index ac327046d7..5a99c53dae 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/llmprovider/OpenAIProvider.kt @@ -3028,7 +3028,7 @@ open class OpenAIProvider( "【发送消息】准备构建请求体,模型参数数量: ${modelParameters.size},已启用参数: ${modelParameters.count { it.isEnabled }}" ) // 直接传递原始历史记录给createRequestBody,让具体的Provider决定如何处理(例如Deepseek需要保留标签) - val requestBody = createRequestBody( + val requestBody = withContext(Dispatchers.IO) { createRequestBody( context, currentHistory, modelParameters, @@ -3036,7 +3036,7 @@ open class OpenAIProvider( effectiveStream, availableTools, preserveThinkInHistory - ) + ) } onTokensUpdated( tokenCacheManager.totalInputTokenCount, tokenCacheManager.cachedInputTokenCount, diff --git a/app/src/main/java/com/ai/assistance/operit/core/agent/collaboration/CollaborationStore.kt b/app/src/main/java/com/ai/assistance/operit/core/agent/collaboration/CollaborationStore.kt index bd1e9a4532..debc0421ea 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/agent/collaboration/CollaborationStore.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/agent/collaboration/CollaborationStore.kt @@ -3,35 +3,145 @@ package com.ai.assistance.operit.core.agent.collaboration import android.content.Context import android.util.AtomicFile import java.io.File +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap +import kotlinx.serialization.Serializable import kotlinx.serialization.encodeToString import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json -/** Atomic private storage; decoding errors are surfaced rather than erasing existing agents. */ -internal class CollaborationStore(context: Context) { - private val file = AtomicFile(File(context.filesDir, "subagent-v2.json")) - private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true } +internal interface CollaborationFileAccess { + fun exists(file: File): Boolean + fun read(file: File): String + fun write(file: File, text: String) +} - fun load(): CollaborationState { - if (!file.baseFile.exists() && !File(file.baseFile.path + ".bak").exists()) { - return CollaborationState() +private object AndroidCollaborationFiles : CollaborationFileAccess { + override fun exists(file: File) = file.exists() || File(file.path + ".bak").exists() + override fun read(file: File) = AtomicFile(file).openRead().bufferedReader().use { it.readText() } + override fun write(file: File, text: String) { + file.parentFile!!.mkdirs() + val atomic = AtomicFile(file) + val output = atomic.startWrite() + try { + output.write(text.toByteArray(Charsets.UTF_8)) + atomic.finishWrite(output) + } catch (error: Throwable) { + atomic.failWrite(output) + throw error } - return file.openRead().bufferedReader(Charsets.UTF_8).use { - json.decodeFromString(it.readText()).also { state -> - require(state.version == 1) { "Unsupported subagent v2 storage version" } + } +} + +/** Immutable tree generations become visible together through one atomic manifest commit. */ +internal class CollaborationStore( + private val filesDir: File, + private val access: CollaborationFileAccess = AndroidCollaborationFiles, +) { + constructor(context: Context) : this(context.filesDir) + + companion object { + const val DIRECTORY = "subagent-v2-store" + const val LEGACY = "subagent-v2.json" + private val locks = ConcurrentHashMap() + + /** Raw restore normally merges files. These alternative formats represent one dataset. */ + fun prepareRestore(payloadFiles: File, targetFiles: File) { + val incomingStore = File(payloadFiles, "$DIRECTORY/manifest.json").isFile + val incomingLegacy = File(payloadFiles, LEGACY).isFile || + File(payloadFiles, "$LEGACY.bak").isFile + if (!incomingStore && !incomingLegacy) return // preserve merge semantics for older backups + synchronized(locks.computeIfAbsent(File(targetFiles, DIRECTORY).absolutePath) { Any() }) { + val oldStore = File(targetFiles, DIRECTORY) + check(!oldStore.exists() || oldStore.deleteRecursively()) { "Cannot replace collaboration store" } + listOf(LEGACY, "$LEGACY.bak", "$LEGACY.new").forEach { name -> + val file = File(targetFiles, name) + check(!file.exists() || file.delete()) { "Cannot replace legacy collaboration store" } + } } } } - fun save(state: CollaborationState) { - val bytes = json.encodeToString(state).toByteArray(Charsets.UTF_8) - val output = file.startWrite() - try { - output.write(bytes) - file.finishWrite(output) - } catch (error: Throwable) { - file.failWrite(output) - throw error + @Serializable + private data class Manifest(val version: Int = 1, val trees: Map = emptyMap()) + + private val directory = File(filesDir, DIRECTORY) + private val manifestFile = File(directory, "manifest.json") + private val legacy = File(filesDir, LEGACY) + private val lock = locks.computeIfAbsent(directory.absolutePath) { Any() } + private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true } + private var committed = emptyMap>() + private var manifest = Manifest() + private var loaded = false + + private fun treeFile(name: String): File { + require(Regex("[a-f0-9-]{36}\\.json").matches(name)) { "Invalid collaboration tree filename" } + return File(directory, name) + } + + fun load(): CollaborationState = synchronized(lock) { + val state = if (access.exists(manifestFile)) { + manifest = json.decodeFromString(access.read(manifestFile)) + require(manifest.version == 1) { "Unsupported collaboration manifest version" } + val agents = manifest.trees.flatMap { (root, name) -> + val tree = json.decodeFromString(access.read(treeFile(name))) + require(tree.version == 1 && tree.agents.isNotEmpty() && + tree.agents.all { it.rootChatId == root }) { "Invalid collaboration tree" } + tree.agents + } + CollaborationState(agents = agents) + } else if (access.exists(legacy)) { + json.decodeFromString(access.read(legacy)).also { + require(it.version == 1) { "Unsupported subagent v2 storage version" } + } + } else CollaborationState() + committed = state.agents.groupBy { it.rootChatId } + loaded = true + state + } + + fun save(state: CollaborationState) = synchronized(lock) { + require(state.version == 1) + if (!loaded) load() + val trees = state.agents.groupBy { it.rootChatId } + val nextFiles = trees.mapValues { (root, agents) -> + val previous = manifest.trees[root] + if (previous != null && agents == committed[root]) previous else { + val name = "${UUID.randomUUID()}.json" + access.write(treeFile(name), json.encodeToString(CollaborationState(agents = agents))) + name + } + } + val next = Manifest(trees = nextFiles) + if (next != manifest || !access.exists(manifestFile)) { + access.write(manifestFile, json.encodeToString(next)) + } + // Nothing after the commit may turn a successful durable save into a reported failure. + manifest = next + committed = trees + runCatching { + val retained = nextFiles.values.toSet() + "manifest.json" + directory.listFiles()?.filter { it.name !in retained }?.forEach { it.delete() } + legacy.delete() + File(legacy.path + ".bak").delete() + } + Unit + } + + /** Copies a consistent snapshot before raw backup walks the rest of filesDir. */ + fun copyForBackup(destinationFiles: File) = synchronized(lock) { + if (access.exists(manifestFile)) { + val text = access.read(manifestFile) + val current = json.decodeFromString(text) + require(current.version == 1) + val target = File(destinationFiles, DIRECTORY).apply { mkdirs() } + current.trees.values.forEach { name -> + File(target, name).writeText(access.read(treeFile(name))) + } + File(target, "manifest.json").writeText(text) + } else if (access.exists(legacy)) { + destinationFiles.mkdirs() + File(destinationFiles, LEGACY).writeText(access.read(legacy)) } } } diff --git a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt index b057f986a9..e685b0e9e4 100644 --- a/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt +++ b/app/src/main/java/com/ai/assistance/operit/data/backup/RawSnapshotBackupManager.kt @@ -188,7 +188,8 @@ object RawSnapshotBackupManager { zos.write(json.encodeToString(manifest).toByteArray(Charsets.UTF_8)) zos.closeEntry() - val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() + val alwaysExcluded = OperitPaths.rawSnapshotExcludedFilesTopLevelDirNames() + + setOf("subagent-v2-store", "subagent-v2.json", "subagent-v2.json.bak", "subagent-v2.json.new") val excludedNames = if (options.includeTerminalData) { alwaysExcluded } else { @@ -218,6 +219,17 @@ object RawSnapshotBackupManager { } withContext(Dispatchers.Main) { onProgress?.invoke(ExportProgressInfo(ExportProgress.ZIPPING_FILES, 0)) } val filesMs = measureTimeMillis { + // Copy referenced generations under the store lock, then release it before ZIP I/O. + val collaborationSnapshot = java.nio.file.Files.createTempDirectory( + context.cacheDir.toPath(), "collaboration-backup-" + ).toFile() + try { + com.ai.assistance.operit.core.agent.collaboration.CollaborationStore(context) + .copyForBackup(collaborationSnapshot) + addDirToZip(zos, collaborationSnapshot, ENTRY_FILES) + } finally { + collaborationSnapshot.deleteRecursively() + } addDirToZip( zos = zos, dir = context.filesDir, @@ -859,6 +871,9 @@ object RawSnapshotBackupManager { AppLogger.i(TAG, "restore replace dirs (preserveTerminalTopLevel=${preservedNames.isNotEmpty()})") withContext(Dispatchers.Main) { onProgress?.invoke(RestoreProgress.REPLACING_FILES) } + com.ai.assistance.operit.core.agent.collaboration.CollaborationStore.prepareRestore( + File(payloadDir, "files"), context.filesDir + ) replaceDirContents(File(payloadDir, "files"), context.filesDir, preservedTopLevelDirNames = preservedNames) if (externalFilesPayloadDir.exists()) { val externalFilesDir = requireNotNull(context.getExternalFilesDir(null)) { diff --git a/app/src/main/java/com/ai/assistance/operit/ui/common/markdown/RenderBatchCoordinator.kt b/app/src/main/java/com/ai/assistance/operit/ui/common/markdown/RenderBatchCoordinator.kt index 8020eb8ea8..4143632335 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/common/markdown/RenderBatchCoordinator.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/common/markdown/RenderBatchCoordinator.kt @@ -19,6 +19,19 @@ internal class RenderBatchCoordinator( private var requestedRevision = 0L private var appliedRevision = 0L private var updateJob: Job? = null + private var generation = 0L + + fun cancelPending() { + generation++ + updateJob?.cancel() + updateJob = null + appliedRevision = requestedRevision + } + + fun flushNow() { + cancelPending() + onFlush() + } fun requestUpdate() { requestedRevision++ @@ -26,6 +39,7 @@ internal class RenderBatchCoordinator( return } + val scheduledGeneration = generation updateJob = scope.launch { try { @@ -36,7 +50,7 @@ internal class RenderBatchCoordinator( appliedRevision = revisionToApply } } finally { - updateJob = null + if (generation == scheduledGeneration) updateJob = null } } } diff --git a/app/src/main/java/com/ai/assistance/operit/ui/common/markdown/StreamMarkdownRenderer.kt b/app/src/main/java/com/ai/assistance/operit/ui/common/markdown/StreamMarkdownRenderer.kt index 15ab0317da..b88a927d3f 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/common/markdown/StreamMarkdownRenderer.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/common/markdown/StreamMarkdownRenderer.kt @@ -465,12 +465,9 @@ fun StreamMarkdownRenderer( // 移除时间计算变量和日志 // 重置状态 - nodes.clear() - renderNodes.clear() - rendererState.collectedContent.clear() + batchUpdater.cancelPending() + rendererState.reset() parsedContent.clear() - xmlNodeStreams.clear() - rendererState.streamParsingCompletedSuccessfully = false try { var pendingHtmlBreakCount = 0 @@ -625,15 +622,7 @@ fun StreamMarkdownRenderer( AppLogger.e(TAG, "【流渲染】Markdown流处理异常: ${e.message}", e) } finally { // 移除时间计算变量和日志 - synchronizeRenderNodes( - nodes, - renderNodes, - conversionCache, - nodeAnimationStates, - xmlNodeStreams, - rendererId, - scope - ) + batchUpdater.flushNow() // 移除最终同步耗时日志 } } @@ -1270,6 +1259,7 @@ internal class BatchNodeUpdater( private val rendererId: String, private val scope: CoroutineScope ) { + private val dirtyIndices = linkedSetOf() private val coordinator = RenderBatchCoordinator( scope = scope, @@ -1277,13 +1267,25 @@ internal class BatchNodeUpdater( onFlush = ::performBatchUpdate, ) - fun requestUpdate() = coordinator.requestUpdate() + fun requestUpdate(nodeIndex: Int = nodes.lastIndex) { + if (nodeIndex >= 0) { + dirtyIndices.add(nodeIndex) + conversionCache.remove(nodeIndex) + } + coordinator.requestUpdate() + } + + fun cancelPending() { + coordinator.cancelPending() + dirtyIndices.clear() + } + + fun flushNow() = coordinator.flushNow() fun requestStructuralUpdate(nodeIndex: Int) { // Type and child-list mutations can preserve content length, so the length-keyed stable // node cache must be invalidated explicitly. - conversionCache.remove(nodeIndex) - requestUpdate() + requestUpdate(nodeIndex) } fun appendBlockChunk(node: MarkdownNode, contentChunk: String) { @@ -1292,6 +1294,8 @@ internal class BatchNodeUpdater( } private fun performBatchUpdate() { + val changed = (dirtyIndices + (renderNodes.size until nodes.size)).sorted() + dirtyIndices.clear() synchronizeRenderNodes( nodes, renderNodes, @@ -1299,7 +1303,8 @@ internal class BatchNodeUpdater( nodeAnimationStates, xmlNodeStreams, rendererId, - scope + scope, + changed, ) } } @@ -1312,12 +1317,14 @@ private fun synchronizeRenderNodes( nodeAnimationStates: MutableMap, xmlNodeStreams: MutableMap>, rendererId: String, - scope: CoroutineScope + scope: CoroutineScope, + changedIndices: Iterable = nodes.indices, ) { val keysToAnimate = mutableListOf() // 1. 更新现有节点并添加新节点 - nodes.forEachIndexed { i, sourceNode -> + changedIndices.forEach { i -> + val sourceNode = nodes.getOrNull(i) ?: return@forEach val contentLength = sourceNode.content.length val cached = conversionCache[i] @@ -1348,21 +1355,20 @@ private fun synchronizeRenderNodes( } // 2. 如果源列表变小,则移除多余的节点 + val previousSize = renderNodes.size while (renderNodes.size > nodes.size) { renderNodes.removeAt(renderNodes.lastIndex) } - // 3. 清理多余的缓存条目 - if (nodes.size < conversionCache.size) { - (nodes.size until conversionCache.size).forEach { - conversionCache.remove(it) + // Append-only batches have no obsolete entries; avoid scanning all XML streams every tick. + if (previousSize > nodes.size) { + conversionCache.keys.filter { it >= nodes.size }.forEach { conversionCache.remove(it) } + xmlNodeStreams.keys.filter { it !in nodes.indices }.forEach { xmlNodeStreams.remove(it) } + (nodes.size until previousSize).forEach { + nodeAnimationStates.remove("node-$rendererId-$it") } } - // 4. 清理已被移除节点的 XML 子流 - val keysToRemove = xmlNodeStreams.keys.filter { it !in nodes.indices } - keysToRemove.forEach { xmlNodeStreams.remove(it) } - // 启动所有新标记节点的动画 if (keysToAnimate.isNotEmpty()) { diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatExportHelper.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatExportHelper.kt index d3d7656ce6..ec1f6c78e1 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatExportHelper.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/LogcatExportHelper.kt @@ -32,6 +32,7 @@ object LogcatExportHelper { suspend fun exportLogs(context: Context): LogcatExportResult = withContext(Dispatchers.IO) { try { + val fullyFlushed = AppLogger.flushFileLogs() val logFile = AppLogger.getLogFile()?.takeIf { it.isFile && it.length() > 0 } val logLineCount = logFile?.let(::countExportableLogLines) ?: 0L if (logLineCount == 0L && !MemoryDiagnostics.hasRecords(context)) { @@ -53,7 +54,8 @@ object LogcatExportHelper { } LogcatExportResult( - message = context.getString(R.string.logcat_saved_to, filePath), + message = if (fullyFlushed) context.getString(R.string.logcat_saved_to, filePath) + else context.getString(R.string.logcat_flush_incomplete, filePath), success = true ) } catch (e: Exception) { diff --git a/app/src/main/java/com/ai/assistance/operit/util/AppLogger.kt b/app/src/main/java/com/ai/assistance/operit/util/AppLogger.kt index e89e5783d3..6c743f94ba 100644 --- a/app/src/main/java/com/ai/assistance/operit/util/AppLogger.kt +++ b/app/src/main/java/com/ai/assistance/operit/util/AppLogger.kt @@ -2,6 +2,7 @@ package com.ai.assistance.operit.util import android.content.Context import android.util.Log +import android.os.Looper import com.ai.assistance.operit.core.application.OperitApplication import java.io.File import java.io.FileWriter @@ -9,8 +10,6 @@ import java.io.IOException import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -import java.util.concurrent.Executors -import java.util.concurrent.RejectedExecutionException import java.util.regex.Pattern /** @@ -86,11 +85,15 @@ object AppLogger { @Volatile private var boundContext: Context? = null - private val fileLogExecutor = Executors.newSingleThreadExecutor { runnable -> - Thread(runnable, "OperitAppLogger").apply { - isDaemon = true - } - } + private data class Record( + val priority: Int = INFO, val tag: String = "", val message: String = "", + val throwable: String? = null, val timestamp: Long = System.currentTimeMillis(), + val reset: Boolean = false, + ) + private val fileQueue = BatchLogQueue(sink = ::writeBatch) + private var batchLogWriter: java.io.BufferedWriter? = null + private var batchPackageWriter: java.io.BufferedWriter? = null + private var batchFailed = false @JvmStatic fun bindContext(context: Context) { @@ -255,6 +258,13 @@ object AppLogger { @JvmStatic fun resetLogFile() { + fileQueue.reset(Record(reset = true)) + } + + /** Call from a background export path, never block the UI waiting for storage. */ + fun flushFileLogs(timeoutMs: Long = 5000): Boolean = fileQueue.flush(timeoutMs) + + private fun resetLogFileSync() { try { val appContext: Context = OperitApplication.instance.applicationContext val dir = File(appContext.filesDir, LOG_DIR_NAME) @@ -273,21 +283,42 @@ object AppLogger { private fun writeToFile(priority: Int, tag: String, msg: String, tr: Throwable?) { if (!enableFileLogging) return + // Bound retained memory before enqueueing; never retain a Throwable's object graph. + val record = Record(priority, tag.take(256), normalizeLogMessage(msg), + tr?.let { ThrowableTextFormatter.format(it, MAX_LOG_THROWABLE_CHARS) }) + val accepted = fileQueue.submit(record, Looper.myLooper() != Looper.getMainLooper()) + if (!accepted) { + Log.w("OperitAppLogger", "File log queue overloaded; UI record omitted (count will be saved)") + } + } + + private fun writeBatch(records: List, dropped: Long): Boolean { + batchFailed = false + fun closeWriters() { + try { batchLogWriter?.close() } catch (_: IOException) { batchFailed = true } + try { batchPackageWriter?.close() } catch (_: IOException) { batchFailed = true } + batchLogWriter = null + batchPackageWriter = null + } try { - fileLogExecutor.execute { - writeToFileSync(priority, tag, msg, tr) + records.forEach { record -> + if (record.reset) { + closeWriters() + resetLogFileSync() + } else writeToFileSync(record) } - } catch (_: RejectedExecutionException) { - } + if (dropped > 0) writeToFileSync(Record(WARN, "OperitAppLogger", + "File log queue overload: $dropped UI/interrupted records omitted; background producers use backpressure.")) + } finally { closeWriters() } + return !batchFailed } - private fun writeToFileSync(priority: Int, tag: String, msg: String, tr: Throwable?) { + private fun writeToFileSync(record: Record) { if (!enableFileLogging) return - val file = resolveLogFile() ?: return + val file = resolveLogFile() ?: run { batchFailed = true; return } + val (priority, tag, normalizedMessage, throwableText) = record - val time = dateFormat.format(Date()) - val normalizedMessage = normalizeLogMessage(msg) - val throwableText = tr?.let { ThrowableTextFormatter.format(it, MAX_LOG_THROWABLE_CHARS) } + val time = dateFormat.format(Date(record.timestamp)) val levelChar = when (priority) { VERBOSE -> 'V' DEBUG -> 'D' @@ -314,11 +345,10 @@ object AppLogger { builder.append('\n') try { - FileWriter(file, true).use { writer -> - writer.write(builder.toString()) - } + val writer = batchLogWriter ?: FileWriter(file, true).buffered().also { batchLogWriter = it } + writer.write(builder.toString()) } catch (e: IOException) { - // Avoid recursive logging here; swallow to prevent crashes + batchFailed = true } writeToPackageLogIfNeeded( @@ -380,10 +410,10 @@ object AppLogger { builder.append('\n') try { - FileWriter(file, true).use { writer -> - writer.write(builder.toString()) - } + val writer = batchPackageWriter ?: FileWriter(file, true).buffered().also { batchPackageWriter = it } + writer.write(builder.toString()) } catch (_: IOException) { + batchFailed = true } } diff --git a/app/src/main/java/com/ai/assistance/operit/util/BatchLogQueue.kt b/app/src/main/java/com/ai/assistance/operit/util/BatchLogQueue.kt new file mode 100644 index 0000000000..da97ebd314 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/util/BatchLogQueue.kt @@ -0,0 +1,79 @@ +package com.ai.assistance.operit.util + +/** Bounded diagnostic queue. UI callers never wait for a slow disk; overload is reported by the sink. */ +internal class BatchLogQueue( + private val capacity: Int = 128, + private val batchSize: Int = 32, + private val sink: (List, Long) -> Boolean, +) { + private data class Item(val sequence: Long, val value: T) + private val lock = Object() + private val pending = ArrayDeque>() + private var submitted = 0L + private var completed = 0L + private var dropped = 0L + private var failed = false + private var inFlight = false + private val worker = Thread(::consume, "OperitAppLogger").apply { isDaemon = true; start() } + + fun submit(value: T, waitForSpace: Boolean): Boolean = synchronized(lock) { + while (pending.size >= capacity) { + if (!waitForSpace || Thread.currentThread() === worker) { + dropped++ + return@synchronized false + } + try { lock.wait() } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + dropped++ + return@synchronized false + } + } + pending.addLast(Item(++submitted, value)) + lock.notifyAll() + true + } + + /** A user-requested reset discards earlier queued records, but executes after the active batch. */ + fun reset(value: T) = synchronized(lock) { + pending.clear() + dropped = 0 + pending.addLast(Item(++submitted, value)) + lock.notifyAll() + } + + fun flush(timeoutMs: Long): Boolean = synchronized(lock) { + if (Thread.currentThread() === worker) return@synchronized false + val target = submitted + val deadline = System.nanoTime() + timeoutMs * 1_000_000 + while (completed < target || dropped > 0 || inFlight) { + val remaining = (deadline - System.nanoTime()) / 1_000_000 + if (remaining <= 0) return@synchronized false + try { lock.wait(remaining.coerceAtLeast(1)) } catch (_: InterruptedException) { + Thread.currentThread().interrupt() + return@synchronized false + } + } + !failed + } + + private fun consume() { + while (true) { + val (batch, lost) = synchronized(lock) { + while (pending.isEmpty() && dropped == 0L) lock.wait() + val items = List(minOf(batchSize, pending.size)) { pending.removeFirst() } + val lost = dropped + dropped = 0 + inFlight = true + lock.notifyAll() + items to lost + } + val success = try { sink(batch.map { it.value }, lost) } catch (_: Throwable) { false } + synchronized(lock) { + if (!success) failed = true + inFlight = false + if (batch.isNotEmpty()) completed = batch.last().sequence + lock.notifyAll() + } + } + } +} diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 316471806c..12f247500f 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -1,4 +1,5 @@ + Available logs saved to %1$s. Some logs are pending or failed to write; this export may be incomplete. On-device LLM inference has been removed. Select a cloud or remote provider. Existing files remain in Download/Operit/models and can be removed using a file manager. The server stopped repetitive model output. The response was discarded and its tools will not run. Repetitive output was discarded. Retrying the request once. diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 78902c54a7..9c6c671812 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,4 +1,5 @@ + 现有日志已保存至 %1$s;部分日志尚未写入或写入失败,导出可能不完整。 此版本已移除手机本地大模型推理。请选择云端或远程服务;原模型文件保留在 Download/Operit/models,可通过文件管理器自行清理。 模型因重复输出被服务端截断,已丢弃异常响应并停止本轮工具执行。 模型出现重复输出,已丢弃异常响应,正在重新请求一次。 diff --git a/app/src/test/java/com/ai/assistance/operit/core/agent/collaboration/CollaborationStoreTest.kt b/app/src/test/java/com/ai/assistance/operit/core/agent/collaboration/CollaborationStoreTest.kt new file mode 100644 index 0000000000..50152e7251 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/core/agent/collaboration/CollaborationStoreTest.kt @@ -0,0 +1,89 @@ +package com.ai.assistance.operit.core.agent.collaboration + +import java.io.File +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder + +class CollaborationStoreTest { + @get:Rule val temp = TemporaryFolder() + private class Files : CollaborationFileAccess { + val writes = mutableListOf() + var failManifest = false + override fun exists(file: File) = file.exists() + override fun read(file: File) = file.readText() + override fun write(file: File, text: String) { + if (failManifest && file.name == "manifest.json") error("disk failure") + writes.add(file.name) + file.parentFile!!.mkdirs() + file.writeText(text) + } + } + private fun agent(root: String) = CollaborationAgent(root, "/root", root) + + @Test fun `one changed tree does not rewrite other histories`() { + val files = Files() + val store = CollaborationStore(temp.root, files) + val a = agent("a") + val state = CollaborationState(agents = listOf(a, agent("b"))) + store.save(state) + files.writes.clear() + store.save(state.update(a.copy(finalAnswer = "finished"))) + assertEquals(2, files.writes.size) // one tree and one manifest + files.writes.clear() + store.save(state.update(a.copy(finalAnswer = "finished"))) + assertTrue(files.writes.isEmpty()) + assertEquals("finished", CollaborationStore(temp.root, files).load().find("a", "/root")!!.finalAnswer) + } + + @Test fun `failed multi tree commit leaves old generation readable and can retry`() { + val files = Files() + val store = CollaborationStore(temp.root, files) + val old = CollaborationState(agents = listOf(agent("a"), agent("b"))) + store.save(old) + val next = old.copy(agents = old.agents.map { it.copy(status = CollaborationStatus.INTERRUPTED) }) + files.failManifest = true + assertThrows(IllegalStateException::class.java) { store.save(next) } + assertEquals(old, CollaborationStore(temp.root, files).load()) + files.failManifest = false + store.save(next) + assertEquals(next, CollaborationStore(temp.root, files).load()) + } + + @Test fun `legacy migration deletion and backup remain complete`() { + val files = Files() + val old = CollaborationState(agents = listOf(agent("a"), agent("b"))) + File(temp.root, CollaborationStore.LEGACY).writeText(Json.encodeToString(old)) + val store = CollaborationStore(temp.root, files) + assertEquals(old, store.load()) + store.save(old) + val backup = temp.newFolder("backup") + store.copyForBackup(backup) + store.save(CollaborationState()) + assertEquals(old, CollaborationStore(backup, files).load()) + assertTrue(CollaborationStore(temp.root, files).load().agents.isEmpty()) + assertFalse(File(temp.root, CollaborationStore.LEGACY).exists()) + } + + @Test fun `corrupt manifest cannot fall back to stale legacy`() { + val files = Files() + File(temp.root, CollaborationStore.LEGACY).writeText(Json.encodeToString(CollaborationState())) + val dir = File(temp.root, CollaborationStore.DIRECTORY).apply { mkdirs() } + File(dir, "manifest.json").writeText("""{"version":1,"trees":{"a":"../outside.json"}}""") + assertThrows(IllegalArgumentException::class.java) { CollaborationStore(temp.root, files).load() } + } + + @Test fun `legacy restore replaces newer format instead of being shadowed`() { + val files = Files() + CollaborationStore(temp.root, files).save(CollaborationState(agents = listOf(agent("new")))) + val backup = temp.newFolder("legacy-backup") + val restored = CollaborationState(agents = listOf(agent("restored"))) + File(backup, CollaborationStore.LEGACY).writeText(Json.encodeToString(restored)) + CollaborationStore.prepareRestore(backup, temp.root) + File(backup, CollaborationStore.LEGACY).copyTo(File(temp.root, CollaborationStore.LEGACY)) + assertEquals(restored, CollaborationStore(temp.root, files).load()) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/ui/common/markdown/RenderBatchCoordinatorTest.kt b/app/src/test/java/com/ai/assistance/operit/ui/common/markdown/RenderBatchCoordinatorTest.kt index e40ee9d76f..84fda63b81 100644 --- a/app/src/test/java/com/ai/assistance/operit/ui/common/markdown/RenderBatchCoordinatorTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/ui/common/markdown/RenderBatchCoordinatorTest.kt @@ -15,6 +15,67 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class RenderBatchCoordinatorTest { + @Test + fun shrinkClearsSparseCachesBeforeReusingAnIndex() = runTest { + val nodes = mutableStateListOf( + MarkdownNode(MarkdownProcessorType.PLAIN_TEXT, "a"), + MarkdownNode(MarkdownProcessorType.PLAIN_TEXT, "b"), + ) + val rendered = mutableStateListOf() + val cache = mutableMapOf>() + val updater = BatchNodeUpdater(nodes, rendered, cache, mutableMapOf(), + mutableMapOf(), "shrink", this) + updater.requestUpdate() + updater.flushNow() + cache.remove(0) + nodes.removeAt(1) + updater.requestUpdate() + updater.flushNow() + org.junit.Assert.assertFalse(cache.containsKey(1)) + nodes.add(MarkdownNode(MarkdownProcessorType.PLAIN_TEXT, "c")) + nodes.add(MarkdownNode(MarkdownProcessorType.PLAIN_TEXT, "d")) + updater.requestUpdate() + updater.flushNow() + assertEquals(listOf("a", "c", "d"), rendered.map { it.content }) + advanceUntilIdle() + } + + @Test + fun thousandCompletedNodesAreNotReconvertedWhenTailChanges() = runTest { + val nodes = mutableStateListOf() + repeat(1000) { nodes.add(MarkdownNode(MarkdownProcessorType.PLAIN_TEXT, "node-$it")) } + val rendered = mutableStateListOf() + val cache = mutableMapOf>() + val updater = BatchNodeUpdater(nodes, rendered, cache, mutableMapOf(), + mutableMapOf(), "large", this) + updater.requestUpdate() + updater.flushNow() + val prefix = rendered.first() + // A missing prefix cache entry would force conversion if an entire-list pass were made. + cache.remove(0) + updater.appendBlockChunk(nodes.last(), " appended") + updater.flushNow() + org.junit.Assert.assertSame(prefix, rendered.first()) + org.junit.Assert.assertFalse(cache.containsKey(0)) + assertEquals("node-999 appended", rendered.last().content) + advanceUntilIdle() + } + + @Test + fun flushAndCancelDoNotLeaveAnOldTimerInANewGeneration() = runTest { + var flushes = 0 + val coordinator = RenderBatchCoordinator(this, 200) { flushes++ } + coordinator.requestUpdate() + coordinator.flushNow() + coordinator.requestUpdate() + advanceUntilIdle() + assertEquals(2, flushes) + coordinator.requestUpdate() + coordinator.cancelPending() + advanceUntilIdle() + assertEquals(2, flushes) + } + @Test fun requestWhileBatchIsPending_isIncludedWithoutAnotherInput() = runTest { var flushCount = 0 diff --git a/app/src/test/java/com/ai/assistance/operit/util/BatchLogQueueTest.kt b/app/src/test/java/com/ai/assistance/operit/util/BatchLogQueueTest.kt new file mode 100644 index 0000000000..46395bedd7 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/util/BatchLogQueueTest.kt @@ -0,0 +1,57 @@ +package com.ai.assistance.operit.util + +import java.util.Collections +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.Assert.* +import org.junit.Test + +class BatchLogQueueTest { + @Test fun `overload returns promptly and is reported while background records remain ordered`() { + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val received = Collections.synchronizedList(mutableListOf()) + var lost = 0L + val queue = BatchLogQueue(capacity = 2, batchSize = 2) { batch, dropped -> + if (batch.contains(0)) { entered.countDown(); release.await(3, TimeUnit.SECONDS) } + received.addAll(batch) + lost += dropped + true + } + queue.submit(0, true) + assertTrue(entered.await(3, TimeUnit.SECONDS)) + assertTrue(queue.submit(1, false)) + assertTrue(queue.submit(2, false)) + assertFalse(queue.submit(3, false)) + assertFalse(queue.flush(10)) + release.countDown() + assertTrue(queue.flush(3000)) + assertEquals(listOf(0, 1, 2), received) + assertEquals(1L, lost) + } + + @Test fun `reset separates active batch from all new records`() { + val entered = CountDownLatch(1) + val release = CountDownLatch(1) + val received = Collections.synchronizedList(mutableListOf()) + val queue = BatchLogQueue(capacity = 2) { batch, _ -> + if (batch.contains(0)) { entered.countDown(); release.await(3, TimeUnit.SECONDS) } + batch.forEach { if (it == -1) received.clear() else received.add(it) } + true + } + queue.submit(0, true) + assertTrue(entered.await(3, TimeUnit.SECONDS)) + queue.submit(1, true) + queue.reset(-1) + queue.submit(2, true) + release.countDown() + assertTrue(queue.flush(3000)) + assertEquals(listOf(2), received) + } + + @Test fun `failed sink is not reported as a successful flush`() { + val queue = BatchLogQueue { _, _ -> false } + queue.submit(1, true) + assertFalse(queue.flush(3000)) + } +} diff --git a/docs/agent/runtime-performance.md b/docs/agent/runtime-performance.md new file mode 100644 index 0000000000..9b9231c6f6 --- /dev/null +++ b/docs/agent/runtime-performance.md @@ -0,0 +1,48 @@ +# Runtime performance and persistence + +## Collaboration storage + +`CollaborationStore` stores immutable per-root task trees under +`files/subagent-v2-store/`. Only changed trees are serialized. An `AtomicFile` +manifest publishes all changed trees together, including startup reconciliation +and multi-tree deletion. A completed task and its parent notification remain in +the same transaction. + +The legacy `subagent-v2.json` is read only when no manifest exists. The first +successful save publishes the new format before removing the legacy file. +A corrupt manifest or missing referenced tree is an error, not a reason to +reload stale legacy data. Orphan generations are cleaned after a successful save. +Older application versions do not read this new format. + +Raw export copies the manifest and its referenced generations under the storage +lock before ZIP compression. Raw restore treats the two collaboration formats as +alternatives: an incoming collaboration dataset replaces both previous formats +and their atomic-file sidecars. Backups with no collaboration data retain the +existing non-destructive merge behavior. + +## File logging + +The file logger queues at most 128 bounded records and writes batches of up to +32 with shared buffered writers. Records contain the original event timestamp +and bounded throwable text, not references to exception object graphs. + +Background producers wait when the queue is full, preserving request-body log +chunks. UI producers never wait for disk capacity: overload is reported to +Android Log and counted in a later file-log warning. A requested clear discards +previously queued records and runs after any active batch, before new records. + +Export waits up to five seconds for logging. It still exports available logs if +that wait or a write fails, with an explicit incomplete-export message. + +## Streaming Markdown + +Streaming batches convert dirty top-level nodes and newly appended nodes only. +Structural updates invalidate cached nodes even when text length is unchanged. +End-of-stream flushes synchronously, and a new stream cancels the previous +timer and clears conversion state. Stable node identities and process expansion +keys are retained. Grouping and layout are still separate work; this does not +make every rendering operation constant time. + +Focused regression suites: `CollaborationStoreTest`, `BatchLogQueueTest`, +`RenderBatchCoordinatorTest`, `ThinkToolsGroupingTest`, `AgentMailboxTest`, +and `RequestBodyLogTest`. From d74d915921bccf5181b1ce9c424a32215db5ac50 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:02:39 +0800 Subject: [PATCH 06/10] fix(tools): repair review calls and clarify recoverable failures --- app/src/main/assets/packages/super_admin.js | 3 ++ .../operit/api/chat/EnhancedAIService.kt | 8 +++- .../api/chat/library/LearningRetryGuard.kt | 23 ++++++++++++ .../api/chat/library/MemoryLearningActions.kt | 36 ++++++++++++++---- .../chat/library/MemoryLearningCoordinator.kt | 37 ++++++++----------- .../api/chat/library/MemoryLearningPrompts.kt | 11 +++--- .../operit/core/tools/ToolCallRepairRouter.kt | 6 +++ .../operit/core/tools/ToolCallRepairRules.kt | 33 +++++++++++++++-- .../toolbox/screens/logcat/ToolRepairPanel.kt | 1 + app/src/main/res/values-en/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../chat/library/LearningRetryGuardTest.kt | 36 ++++++++++++++++++ .../core/tools/ToolCallRepairRouterTest.kt | 30 +++++++++++++++ docs/agent/tool-call-repairs.md | 13 ++++++- examples/super_admin.js | 3 ++ examples/super_admin.ts | 3 ++ 16 files changed, 202 insertions(+), 43 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/api/chat/library/LearningRetryGuard.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/api/chat/library/LearningRetryGuardTest.kt diff --git a/app/src/main/assets/packages/super_admin.js b/app/src/main/assets/packages/super_admin.js index f8f42bbba3..dde9f1a0af 100644 --- a/app/src/main/assets/packages/super_admin.js +++ b/app/src/main/assets/packages/super_admin.js @@ -249,6 +249,9 @@ const superAdmin = (function () { */ async function terminal_wait(params = {}) { try { + if (Object.prototype.hasOwnProperty.call(params, "command")) { + throw new Error("terminal_wait only waits; command was not executed. Use super_admin:terminal to execute a command."); + } const timeoutMs = params.timeoutMs; let timeout = DEFAULT_WAIT_TIMEOUT_MS; if (timeoutMs !== undefined) { diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt index d5d0d45492..c902acfa83 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt @@ -2172,7 +2172,9 @@ class EnhancedAIService private constructor( } val terminalInvocations = - toolInvocations.filter { invocation -> invocation.tool.name in context.terminalToolNames } + toolInvocations.filter { invocation -> + com.ai.assistance.operit.core.tools.ToolCallRepairRouter.terminalToolName(invocation) in context.terminalToolNames + } if (terminalInvocations.isNotEmpty() && toolInvocations.size != 1) { AppLogger.w(TAG, "Terminal result turn must contain exactly one tool call") finalizeAssistantResponse( @@ -2316,7 +2318,9 @@ class EnhancedAIService private constructor( } if ( turnSignal == ToolTurnSignal.COMPLETE || - toolInvocations.singleOrNull()?.tool?.name in context.terminalToolNames + toolInvocations.singleOrNull()?.let { + com.ai.assistance.operit.core.tools.ToolCallRepairRouter.terminalToolName(it) + } in context.terminalToolNames ) { finalizeAssistantResponse( context = context, diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/library/LearningRetryGuard.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/library/LearningRetryGuard.kt new file mode 100644 index 0000000000..fb8ffd80f3 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/library/LearningRetryGuard.kt @@ -0,0 +1,23 @@ +package com.ai.assistance.operit.api.chat.library + +import java.security.MessageDigest + +/** Session-lock confined. A successful read/edit can change the conditions behind a rejection. */ +internal class LearningRetryGuard { + private val failures = HashMap() + fun isBlocked(key: String) = (failures[key] ?: 0) >= LEARNING_REPEAT_FAILURE_LIMIT + fun onFailure(key: String): Int = + ((failures[key] ?: 0) + 1).also { failures[key] = it } + fun onSuccess() = failures.clear() +} + +/** Retain only a digest, not another copy of potentially large document/tool arguments. */ +internal fun learningRetryKey(action: String, args: Map): String { + val digest = MessageDigest.getInstance("SHA-256") + for (part in listOf(action) + args.toSortedMap().flatMap { listOf(it.key,it.value) }) { + digest.update(part.length.toString().toByteArray(Charsets.UTF_8)) + digest.update(':'.code.toByte()) + digest.update(part.toByteArray(Charsets.UTF_8)) + } + return digest.digest().joinToString("") { "%02x".format(it) } +} diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLearningActions.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLearningActions.kt index d31c897969..41774d74e5 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLearningActions.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLearningActions.kt @@ -55,6 +55,9 @@ class MemoryLearningActions( val version = LearnedSkillRepository.version(disk) rememberVersion(if(user) "user" else "memory", version) JSONObject().put("content",content).put("version",version).put("staged",staged!=null).apply { + val limit = if (user) UserProfileDocumentRepository.MAX_CONTENT_CHARS else MemoryNotesRepository.MAX_CHARS + put("current_chars",content.length).put("max_chars",limit) + .put("remaining_chars",(limit-content.length).coerceAtLeast(0)) if (user) { val sections = UserProfileSections.parse(content) put("sections", JSONObject().put("profile", sections.profile) @@ -92,7 +95,7 @@ class MemoryLearningActions( } val change = if(user) { require(after.length<=UserProfileDocumentRepository.MAX_CONTENT_CHARS) { - "user.md would exceed its character limit; remove or replace existing text in this batch before adding" + memoryCapacityError("user.md",base.length,after.length,UserProfileDocumentRepository.MAX_CONTENT_CHARS) } reviews.proposeUser(disk,after,sourceChatId,onCreated) } else { @@ -100,7 +103,7 @@ class MemoryLearningActions( val before = repo.load() check(before.markdown==disk) require(after.length<=MemoryNotesRepository.MAX_CHARS) { - MemoryNotesRepository.OVERFLOW_MESSAGE + memoryCapacityError("memory.md",base.length,after.length,MemoryNotesRepository.MAX_CHARS) } reviews.proposeNotes(before,after,if(operation=="add") content else "",sourceChatId,onCreated) } @@ -187,7 +190,7 @@ class MemoryLearningActions( stagedSkillCreate(stagedChanges,name)?.let { created -> LearnedSkillRepository.validatePath(path) check(readVersion("$name/$path")=="draft") { - "Call skill_read with this name and path before editing the draft file" + skillReadRequired(name,path) } val before = if(path=="SKILL.md") created.body else created.files[path].orEmpty() val text = if(action=="skill_patch") editText(before,"replace",content,arg("old_text")) @@ -209,7 +212,7 @@ class MemoryLearningActions( } val before = skills.read(name,path) check((readVersion("$name/$path") ?: arg("version"))==before.version) { - "Call skill_read with this name and path before editing the file" + skillReadRequired(name,path) } val remove = action=="skill_remove_file" // A revision patches the version this batch staged, not the untouched file on disk. @@ -226,7 +229,8 @@ class MemoryLearningActions( if (stagedChanges==null) forgetVersion("$name/$path") reviews.toModelJson(applied) } - else -> error("Unknown learning action") + "memory_learning_finish" -> error("memory_learning_finish is a separate tool, not an action. Call it directly without arguments; this call did not finish the review.") + else -> error("Unknown learning action; use an action listed in memory_learning_action's description") } } } @@ -244,7 +248,8 @@ private fun notesEdit(base: String, operation: String, content: String, oldText: throw IllegalArgumentException(when (e.reason) { MemoryNotesRepository.Failure.EMPTY -> if (operation == "add") "content is required" else "old_text and content are required" - MemoryNotesRepository.Failure.NOT_UNIQUE -> "old_text must match exactly once" + MemoryNotesRepository.Failure.NOT_UNIQUE -> exactEditError( + stripInvisibleCharacters(base),stripInvisibleCharacters(oldText)) MemoryNotesRepository.Failure.CONFLICT -> "memory.md changed while this batch ran; read it again" MemoryNotesRepository.Failure.FULL -> MemoryNotesRepository.OVERFLOW_MESSAGE MemoryNotesRepository.Failure.INVALID -> "Use add/replace/remove" @@ -284,10 +289,25 @@ internal fun editText(current: String, operation: String, content: String, old: else listOf(current.trimEnd(),content.trim()).filter { it.isNotEmpty() }.joinToString("\n\n") } "replace","remove" -> { - require(old.isNotBlank()) + require(old.isNotBlank()) { "old_text is required; read the current content before editing" } val at = current.indexOf(old) - require(at>=0 && current.indexOf(old,at+1)<0) { "old_text must match exactly once" } + require(at>=0 && current.indexOf(old,at+1)<0) { exactEditError(current,old) } current.replaceRange(at,at+old.length,if(operation=="remove") "" else content) } else -> error("Use add/replace/remove") } + +internal fun memoryCapacityError(document: String, before: Int, after: Int, limit: Int) = + "$document capacity exceeded: current_chars=$before, proposed_chars=$after, max_chars=$limit, " + + "over_by=${after-limit}. Read the latest staged content with memory_read, then shorten or remove " + + "existing text by at least ${after-limit} characters before retrying. A replacement can also exceed the limit." + +internal fun exactEditError(current: String, old: String): String { + val match = if (old.isEmpty() || !current.contains(old)) "0" else "more than 1" + return "old_text must match exactly once; found $match matches. Read the latest staged content with " + + "memory_read or skill_read and copy an exact, unique span (include more surrounding text if ambiguous)." +} + +private fun skillReadRequired(name: String, path: String) = + "Read the current file first: action=skill_read, arguments=" + + JSONObject().put("name",name).put("path",path).toString() + "; then retry the edit." diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLearningCoordinator.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLearningCoordinator.kt index 099b659a55..a145476528 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLearningCoordinator.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLearningCoordinator.kt @@ -91,18 +91,10 @@ object MemoryLearningCoordinator { * list is synchronised instead of relying on both sides happening to hold the same lock. */ private val failures = java.util.Collections.synchronizedList(mutableListOf()) - private val consecutiveFailures = HashMap() + val retryGuard = LearningRetryGuard() fun addFailure(detail: String) { failures.add(detail) } fun failureList(): List = synchronized(failures) { failures.toList() } fun failureCount(): Int = synchronized(failures) { failures.size } - /** - * Counts repeated failures of one action. A batch that retries the same rejected call burns - * its round budget and is then discarded, so after [LEARNING_REPEAT_FAILURE_LIMIT] the action - * is closed for this batch and the reviewer is told to finish instead. - */ - fun recordRepeatFailure(key: String): Int = (consecutiveFailures[key] ?: 0) + 1 - .also { consecutiveFailures[key] = it } - fun clearRepeatFailure(key: String) { consecutiveFailures.remove(key) } var persistProgress: suspend () -> Unit = {} var recordModelRound: () -> Unit = {} val evidenceBytes = AtomicLong() @@ -521,9 +513,8 @@ object MemoryLearningCoordinator { }) val args = tool.parameters.associate { it.name to it.value } val actionName = args["action"].orEmpty() - // The rejection counter is keyed by the action and the target it was aimed at: three - // recoverable mistakes on three different files must not close a whole operation. - var repeatKey = actionName + // Corrected input must remain executable; retain only a digest of full arguments. + var repeatKey = learningRetryKey(actionName,args) var repeatTarget = "" try { if(tool.name==FINISH) { @@ -533,10 +524,14 @@ object MemoryLearningCoordinator { val json = JSONObject(args["arguments"].orEmpty().ifBlank { "{}" }) val params = json.keys().asSequence().associateWith { json.get(it).toString() } val parts = listOf("target","name","path","section").map { params[it].orEmpty() } - repeatKey = actionName + "|" + parts.joinToString("|") + repeatKey = learningRetryKey(actionName,params) repeatTarget = parts.filter { it.isNotBlank() }.joinToString(" ") + check(!session.retryGuard.isBlocked(repeatKey)) { + "These identical arguments already failed $LEARNING_REPEAT_FAILURE_LIMIT times. " + + "Read the latest content, correct the arguments, or skip this change and call $FINISH." + } val result = session.actions.execute(actionName,params) - session.clearRepeatFailure(repeatKey) + session.retryGuard.onSuccess() session.roundNotice()?.let { result.put("notice",it) } val resultText = result.toString() session.evidenceBytes.addAndGet(resultText.toByteArray(Charsets.UTF_8).size.toLong()) @@ -548,17 +543,15 @@ object MemoryLearningCoordinator { // A run of rejected tool calls is exactly when the reviewer needs to know the budget is // nearly gone, so the pacing hint rides on the error too. val notice = session.roundNotice() - val repeats = session.recordRepeatFailure(repeatKey) + val repeats = session.retryGuard.onFailure(repeatKey) if (repeats >= LEARNING_REPEAT_FAILURE_LIMIT) { - // Retrying the same rejected call cannot succeed and would spend the whole round - // budget, which discards the batch and its staged changes. The action is closed - // for this batch instead, so the reviewer submits what it already has. - // Only this target is closed, so the message must say which one. + // Rejections are failures, even when we also give a recovery/budget hint. val target = repeatTarget.takeIf { it.isNotBlank() }?.let { " on $it" }.orEmpty() - val message = "$actionName$target was rejected $repeats times and is closed for this batch. " + - "Stop retrying it, submit what already qualifies and call $FINISH." + + val message = "${e.message.orEmpty()} $actionName$target failed with identical arguments $repeats times. " + + "Do not resend unchanged arguments. Read the latest content and correct the input, " + + "or skip this change and call $FINISH." + (notice?.let { " $it" } ?: "") - ToolResult(toolName=tool.name,success=true,result=StringResultData(message)) + ToolResult(toolName=tool.name,success=false,result=StringResultData(""),error=message) } else ToolResult(toolName=tool.name,success=false,result=StringResultData(""), error=e.message.orEmpty().let { message -> notice?.let { message+" $it" } ?: message }) } finally { session.persistProgress() } diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLearningPrompts.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLearningPrompts.kt index 662e90f821..70194f575c 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLearningPrompts.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/library/MemoryLearningPrompts.kt @@ -10,9 +10,7 @@ import com.ai.assistance.operit.data.preferences.LearnedSkillRepository internal const val LEARNING_ROUND_LIMIT = 20 internal const val LEARNING_TOOL_CALL_LIMIT = 60 /** - * How often one action may fail in a row before it is closed for the rest of the batch. Retrying a - * rejected call cannot succeed, and a batch that spends its rounds on it is discarded along with - * everything it already staged. + * Identical failures allowed without successful intervening work. Corrected inputs remain usable. */ internal const val LEARNING_REPEAT_FAILURE_LIMIT = 3 @@ -103,10 +101,11 @@ internal fun buildMemoryLearningInstructions(chatId: String, notes: Boolean, ski user must clear that list before more are accepted. No fabricated successful testing. If nothing qualifies, do not invent a change. If an operation is outside this run's scope, do not retry it; continue the enabled work or finish. - If the same operation on the same target is rejected, the reason will not change by resending - it: fix the input or drop that change. Repeating it $LEARNING_REPEAT_FAILURE_LIMIT times closes - that operation for this batch, so switch to the rest of the work instead. + Do not resend rejected arguments unchanged. After $LEARNING_REPEAT_FAILURE_LIMIT identical failures, + that input is blocked until a successful read or edit. Read the latest staged content, correct + the input (including capacity), or skip that change. Other corrected operations remain available. Call $finish after reviewing all provided source, even when no changes qualify. This is mandatory. + $finish is a separate tool called without arguments, never an action inside the action tool. A final summary alone does not confirm completion. At most $LEARNING_ROUND_LIMIT model rounds and $LEARNING_TOOL_CALL_LIMIT tool calls. Once within two rounds of that limit, stop exploring: submit the best complete change you already have and call $finish, because an unfinished batch is discarded and reviewed again later. diff --git a/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouter.kt b/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouter.kt index e3dc170687..21dae7ad50 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouter.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouter.kt @@ -14,6 +14,12 @@ internal object ToolCallRepairRouter { const val REDUNDANT_PACKAGE_NAME = "redundant_proxy_package_name" const val PROXY_FLATTENED_TOOL_NAME = "proxy_flattened_tool_name" const val MEMORY_ARGUMENT_ALIAS = "memory_argument_alias" + const val MEMORY_FINISH_ALIAS = "memory_finish_alias" + + fun terminalToolName(invocation: ToolInvocation): String = + if (invocation.tool.name == "memory_learning_action") + route(invocation)?.invocation?.tool?.name ?: invocation.tool.name + else invocation.tool.name data class Repair( val original: ToolInvocation, diff --git a/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRules.kt b/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRules.kt index 5f29ad9331..cdc100f67f 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRules.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/tools/ToolCallRepairRules.kt @@ -24,6 +24,21 @@ internal object ToolCallRepairRules { // Name normalization precedes canonical-name rules. One pass, no retry loop. val ordered = listOf( + ToolCallRepairRule(ToolCallRepairRouter.MEMORY_FINISH_ALIAS) { call -> + if (call.targetName != "memory_learning_action" || call.proxyParameters != null || + call.arguments.any { it.name !in setOf("action","arguments") } || + call.arguments.groupBy { it.name }.any { it.value.size != 1 } || + (call.arguments.singleOrNull { it.name == "action" }?.value as? JsonPrimitive)?.content != "memory_learning_finish") + return@ToolCallRepairRule null + val field = call.arguments.singleOrNull { it.name == "arguments" } + if (field != null) { + val raw = (field.value as? JsonPrimitive)?.takeIf { it.isString }?.content + ?: return@ToolCallRepairRule null + if (ToolCallRepairRouter.parseStrictJsonObject(raw)?.isEmpty() != true) + return@ToolCallRepairRule null + } + call.copy(targetName = "memory_learning_finish", arguments = emptyList()) + }, ToolCallRepairRule(ToolCallRepairRouter.TERMINAL_SEPARATOR) { call -> // Any run of separators is the same typo; every other name stays untouched. if (repeatedTerminalSeparator.matches(call.targetName)) @@ -46,15 +61,27 @@ internal object ToolCallRepairRules { if (call.targetName != "super_admin:terminal") return@ToolCallRepairRule null val alias = call.arguments.singleOrNull { it.name == "timeout" } ?: return@ToolCallRepairRule null - val timeout = (alias.value as? JsonPrimitive)?.content?.toLongOrNull() - ?.takeIf { it in 3_000..Int.MAX_VALUE.toLong() } ?: return@ToolCallRepairRule null + val rawTimeout = (alias.value as? JsonPrimitive)?.content?.toLongOrNull() + ?: return@ToolCallRepairRule null + // Legacy millisecond aliases remain unchanged. Small integral values have an explicit + // seconds compatibility range; the ambiguous gap is deliberately not guessed. + val timeout = when (rawTimeout) { + in 3..300 -> rawTimeout * 1_000 + in 3_000..Int.MAX_VALUE.toLong() -> rawTimeout + else -> return@ToolCallRepairRule null + } val canonical = call.arguments.filter { it.name == "timeoutMs" } if (canonical.size > 1 || (canonical.size == 1 && (canonical.single().value as? JsonPrimitive)?.content?.toLongOrNull() != timeout)) { return@ToolCallRepairRule null } call.copy(arguments = if (canonical.isEmpty()) { - call.arguments.map { if (it.name == "timeout") it.copy(name = "timeoutMs") else it } + call.arguments.map { + if (it.name != "timeout") it else it.copy(name = "timeoutMs", + value = if (rawTimeout == timeout) it.value + else if ((it.value as JsonPrimitive).isString) JsonPrimitive(timeout.toString()) + else JsonPrimitive(timeout)) + } } else { call.arguments.filterNot { it.name == "timeout" } }) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/ToolRepairPanel.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/ToolRepairPanel.kt index 5ffeed5588..472df63f57 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/ToolRepairPanel.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/toolbox/screens/logcat/ToolRepairPanel.kt @@ -92,6 +92,7 @@ internal fun ToolRepairPanel(modifier: Modifier = Modifier) { ToolCallRepairRouter.REDUNDANT_PACKAGE_NAME -> R.string.tool_repairs_package ToolCallRepairRouter.PROXY_FLATTENED_TOOL_NAME -> R.string.tool_repairs_flattened_proxy ToolCallRepairRouter.MEMORY_ARGUMENT_ALIAS -> R.string.tool_repairs_memory_argument + ToolCallRepairRouter.MEMORY_FINISH_ALIAS -> R.string.tool_repairs_memory_finish else -> null } Text(if (label == null) rule else stringResource(label)) diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 12f247500f..2983319aad 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -8844,6 +8844,7 @@ Removed redundant package_name matching the target package Recovered the tool_name that was folded into the params text Renamed the memory tool\'s new_text argument to content + Routed an empty finish action to the separate finish tool Invalid record format; tap to view the raw record Refresh %1$d records · %2$d tools diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9c6c671812..bec29183d3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8820,6 +8820,7 @@ 移除与目标工具包一致的重复 package_name 从 params 文本中还原出被并入的 tool_name 将记忆工具误写的 new_text 参数改为 content + 将误放在 action 中的结束操作改为独立结束工具 记录格式无效,点击查看原始记录 刷新 %1$d 条记录 · %2$d 个工具 diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/library/LearningRetryGuardTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/library/LearningRetryGuardTest.kt new file mode 100644 index 0000000000..6fe4bcddbb --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/library/LearningRetryGuardTest.kt @@ -0,0 +1,36 @@ +package com.ai.assistance.operit.api.chat.library + +import org.junit.Assert.* +import org.junit.Test + +class LearningRetryGuardTest { + @Test fun identicalFailuresBlockButCorrectedInputsAndSuccessfulWorkRecover() { + val guard = LearningRetryGuard() + val key = learningRetryKey("memory_change", mapOf("content" to "long", "operation" to "add")) + val reordered = learningRetryKey("memory_change", mapOf("operation" to "add", "content" to "long")) + assertEquals(key,reordered) + repeat(2) { guard.onFailure(key) } + assertFalse(guard.isBlocked(key)) + guard.onFailure(key) + assertTrue(guard.isBlocked(key)) + assertFalse(guard.isBlocked(learningRetryKey("memory_change", mapOf("content" to "short","operation" to "add")))) + guard.onSuccess() + assertFalse(guard.isBlocked(key)) + } + + @Test fun capacityErrorReportsTheProposedReplacementSize() { + val error = memoryCapacityError("memory.md",5900,6100,6000) + assertTrue(error.contains("current_chars=5900")) + assertTrue(error.contains("proposed_chars=6100")) + assertTrue(error.contains("over_by=100")) + } + + @Test fun exactEditsRemainExactAndGiveRecoveryAdvice() { + for ((text,old,reason) in listOf(Triple("abc","xyz","0 matches"),Triple("abc abc","abc","more than 1"))) { + val error = runCatching { editText(text,"replace","new",old) }.exceptionOrNull()!! + assertTrue(error.message!!.contains(reason)) + assertTrue(error.message!!.contains("skill_read")) + } + assertEquals("a NEW c",editText("a old c","replace","NEW","old")) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouterTest.kt b/app/src/test/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouterTest.kt index e96229bc54..997dc8c5ea 100644 --- a/app/src/test/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouterTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/core/tools/ToolCallRepairRouterTest.kt @@ -8,6 +8,36 @@ import org.junit.Assert.* import org.junit.Test class ToolCallRepairRouterTest { + @Test fun terminalSecondsCompatibilityConvertsAndChecksCanonicalConflict() { + for (seconds in listOf(3,60,90,300)) { + val result = ToolCallRepairRouter.route(call("super_admin:terminal","timeout" to "$seconds"))!!.invocation + assertEquals("${seconds*1000}",result.tool.parameters.single().value) + assertNull(ToolCallRepairRouter.route(result)) + } + assertNull(ToolCallRepairRouter.route(call("super_admin:terminal","timeout" to "301"))) + assertNull(ToolCallRepairRouter.route(call("super_admin:terminal","timeout" to "60","timeoutMs" to "60"))) + assertEquals(listOf(ToolParameter("timeoutMs","60000")),ToolCallRepairRouter.route( + call("super_admin:terminal","timeout" to "60","timeoutMs" to "60000"))!!.invocation.tool.parameters) + val proxy = ToolCallRepairRouter.route(call("proxy","tool_name" to "super_admin:terminal", + "params" to """{"timeout":90}"""))!!.invocation + assertEquals(90000,JSONObject(proxy.tool.parameters.last().value).getInt("timeoutMs")) + } + + @Test fun finishAliasRequiresEmptyInputAndRetainsIdentityAndTerminalSemantics() { + val original = call("memory_learning_action","action" to "memory_learning_finish","arguments" to "{}") + val result = ToolCallRepairRouter.route(original)!!.invocation + assertEquals("memory_learning_finish",result.tool.name) + assertTrue(result.tool.parameters.isEmpty()) + assertEquals(original.callId,result.callId) + assertEquals(result.tool.name,ToolCallRepairRouter.terminalToolName(original)) + assertNull(ToolCallRepairRouter.route(result)) + for (raw in listOf("""{"name":"x"}""","null","", """{"a":1,"a":2}""")) { + assertNull(ToolCallRepairRouter.route(call("memory_learning_action", + "action" to "memory_learning_finish","arguments" to raw))) + } + assertNull(ToolCallRepairRouter.route(call("memory_learning_action","action" to "memory_learning_finish","extra" to "x"))) + assertNull(ToolCallRepairRouter.route(call("learning_manage","action" to "memory_learning_finish","arguments" to "{}"))) + } private fun call(name: String, vararg params: Pair) = ToolInvocation(AITool(name, params.map { ToolParameter(it.first, it.second) }), rawText = "original provider response", responseLocation = 4..12, diff --git a/docs/agent/tool-call-repairs.md b/docs/agent/tool-call-repairs.md index b80027a3e2..53bfc86d11 100644 --- a/docs/agent/tool-call-repairs.md +++ b/docs/agent/tool-call-repairs.md @@ -6,7 +6,8 @@ - `super_admin` 命名空间下重复冒号的 `terminal`(如 `super_admin::terminal`、 `super_admin:::terminal`)精确修正为 `super_admin:terminal`;其他名字不动。 - 仅对 `super_admin:terminal`,将有效整数毫秒 `timeout` 改名为 `timeoutMs`。 - 两者值相同时移除重复别名,冲突时不改;支持范围为 3000 到 Int.MAX_VALUE 毫秒。 + 兼容约定:3~300 的整数按秒乘以 1000;3000 到 Int.MAX_VALUE 按毫秒保留; + 其余值不猜测。与 timeoutMs 换算后相等时移除别名,冲突时不改。正式参数仍为 timeoutMs。 - `proxy` / `package_proxy` 外层的 `package_name` 与目标包名前缀完全相同时移除重复字段。 - 代理调用没有声明 `tool_name`(缺失或空白)时,如果 `params` 末尾并入了目标名, 且只可能是 `<对象>, {"tool_name":"x"}` 或 `<对象>, "tool_name":"x"}` 这两种形态之一, @@ -16,7 +17,10 @@ 属于读取 `content` 的 `memory_change` / `skill_create` / `skill_write` / `skill_patch` / `skill_remove_file` 时,将 `new_text` 改名为 `content`。 - 支持直接调用和代理调用,保留代理外壳;`package_proxy` 仍只接受带包名的工具。 -- 保留参数值、调用 ID、原始响应与调用位置;不补全缺失参数、不修正路径。 +- 直接调用 `memory_learning_action` 时,精确的 `action=memory_learning_finish`,且 + `arguments` 缺失或为严格空 JSON 对象、无额外参数,改为独立的 `memory_learning_finish`。 + 终止批次识别也使用该结果;非空参数、代理调用不做此修复,仍需能力鉴权。 +- 除上述明确的秒转毫秒规则,保留参数值;保留调用 ID、原始响应与调用位置,不修正路径。 代理参数需要改名时重新编码 JSON,保留值及类型;只改工具名时保留原始 JSON 文本。 - 格式无效、重复的外层参数或 JSON 任意层级重复键不自动修复;嵌套的 `arguments` 文本与代理 `params` 共用同一套严格解析,重复键会拒绝修复而不是静默折叠。 @@ -28,6 +32,11 @@ 转换后的工具经过原有工具可用性、角色卡和权限检查。原始调用继续用于重复调用检测。 +`terminal_wait` 如收到 `command` 会明确拒绝并告知命令未执行,不会静默忽略或自动执行。 +记忆读取返回当前字符数、上限和剩余量;超限错误包含本次拟写入的大小和超出量。 +连续三次完全相同的记忆调用失败后,拦截相同输入并如实返回失败;修正输入仍可调用, +成功读取或其他操作会重置计数,以便处理容量、版本变化后重试。 + 每次转换独立记录到应用私有目录 `files/tool_call_repairs.jsonl`,每行一个 JSON 对象: 时间戳、调用 ID、调用序号、原工具名、目标工具名、全部命中规则和转换前后参数名称。 保留旧 `rule` 字段(第一条命中规则);新日志使用 `rules` 数组,查看器也兼容旧日志。 diff --git a/examples/super_admin.js b/examples/super_admin.js index f8f42bbba3..dde9f1a0af 100644 --- a/examples/super_admin.js +++ b/examples/super_admin.js @@ -249,6 +249,9 @@ const superAdmin = (function () { */ async function terminal_wait(params = {}) { try { + if (Object.prototype.hasOwnProperty.call(params, "command")) { + throw new Error("terminal_wait only waits; command was not executed. Use super_admin:terminal to execute a command."); + } const timeoutMs = params.timeoutMs; let timeout = DEFAULT_WAIT_TIMEOUT_MS; if (timeoutMs !== undefined) { diff --git a/examples/super_admin.ts b/examples/super_admin.ts index 4561bd2a42..bbb72954bd 100644 --- a/examples/super_admin.ts +++ b/examples/super_admin.ts @@ -270,6 +270,9 @@ const superAdmin = (function () { */ async function terminal_wait(params: { sessionId?: string, timeoutMs?: string } = {}): Promise { try { + if (Object.prototype.hasOwnProperty.call(params, "command")) { + throw new Error("terminal_wait only waits; command was not executed. Use super_admin:terminal to execute a command."); + } const timeoutMs = params.timeoutMs; let timeout = DEFAULT_WAIT_TIMEOUT_MS; if (timeoutMs !== undefined) { From db9f2a243d40f38e6ae237c9618824d72b0d9972 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Thu, 8 Oct 2026 06:02:46 +0800 Subject: [PATCH 07/10] fix(chat): preserve transcript position when returning from subagents --- .../chat/components/VirtualTranscript.kt | 56 ++++++++++++------- 1 file changed, 37 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/VirtualTranscript.kt b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/VirtualTranscript.kt index cd7e6e212d..3457ffb783 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/VirtualTranscript.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/features/chat/components/VirtualTranscript.kt @@ -43,7 +43,7 @@ import com.ai.assistance.operit.ui.features.chat.components.style.bubble.remembe import androidx.compose.ui.text.rememberTextMeasurer import com.ai.assistance.operit.ui.common.markdown.LocalTranscriptTextMeasurer -private data class TranscriptBookmark(val key: String, val offset: Int) +private data class TranscriptBookmark(val key: String, val offset: Int, val timestamp: Long?) private object TranscriptBookmarks { private val entries = LinkedHashMap() fun get(chatId: String) = entries[chatId] @@ -132,15 +132,14 @@ internal fun VirtualTranscript( } val bookmark = remember(chatId) { TranscriptBookmarks.get(chatId) } var restoring by remember(chatId) { mutableStateOf(bookmark != null) } - var restoreRequested by remember(chatId) { mutableStateOf(false) } - val restoreTimestamp = remember(bookmark) { - bookmark?.key?.takeIf { it.startsWith("message:") }?.split(':')?.getOrNull(1)?.toLongOrNull() - } + var restoreUnavailable by remember(chatId) { mutableStateOf(false) } + val restoreTimestamp = bookmark?.timestamp val listState = remember(chatId) { LazyListState( cacheWindow = LazyLayoutCacheWindow(aheadFraction = 1f, behindFraction = 1f), firstVisibleItemIndex = rows.indexOfFirst { it.key == bookmark?.key }.coerceAtLeast(0), - firstVisibleItemScrollOffset = bookmark?.offset ?: 0, + // The saved markdown slice may not exist until preparation finishes. + firstVisibleItemScrollOffset = 0, ) } val scope = rememberCoroutineScope() @@ -155,9 +154,10 @@ internal fun VirtualTranscript( } } var pendingJump by remember(chatId) { mutableStateOf(null) } - val currentRows by rememberUpdatedState(rows) + val currentRows by key(chatId) { rememberUpdatedState(rows) } val currentBaseRows by rememberUpdatedState(baseRows) - val currentMessages by rememberUpdatedState(messages) + val currentMessages by key(chatId) { rememberUpdatedState(messages) } + val currentReveal by rememberUpdatedState(reveal) val prepareCandidates by remember(listState) { derivedStateOf { val indices = listState.layoutInfo.visibleItemsInfo.mapNotNull { currentRows.getOrNull(it.index)?.messageIndex?.takeIf { index -> index >= 0 } @@ -186,7 +186,15 @@ internal fun VirtualTranscript( } } } - LaunchedEffect(chatId, rows.map { it.key to it.preparingMarkdown }, restoring) { + // Loading the missing anchor must survive row changes from asynchronous markdown preparation. + // A rows-keyed effect can cancel the IO after marking it requested and never retry it. + LaunchedEffect(chatId, bookmark, restoring) { + if (restoring && restoreTimestamp != null && + currentMessages.none { it.timestamp == restoreTimestamp }) { + restoreUnavailable = currentReveal?.invoke(restoreTimestamp) != true + } + } + LaunchedEffect(chatId, rows.map { it.key to it.preparingMarkdown }, restoring, restoreUnavailable) { if (!restoring || bookmark == null) return@LaunchedEffect onFollowingChange?.invoke(false) val index = rows.indexOfFirst { it.key == bookmark.key } @@ -203,10 +211,7 @@ internal fun VirtualTranscript( listState.scrollToItem(fallback) restoring = false } - } else if (restoreTimestamp != null && !restoreRequested) { - restoreRequested = true - if (reveal?.invoke(restoreTimestamp) != true) restoring = false - } else if (restoreTimestamp == null) { + } else if (restoreTimestamp == null || restoreUnavailable) { restoring = false } } @@ -236,13 +241,26 @@ internal fun VirtualTranscript( } } } + fun currentBookmark(): TranscriptBookmark? { + // visibleItemsInfo also contains rows in contentPadding; their key must not be paired + // with another row's firstVisibleItemScrollOffset. + val item = listState.layoutInfo.visibleItemsInfo.firstOrNull { + it.index == listState.firstVisibleItemIndex + } ?: return null + val row = currentRows.firstOrNull { it.key == item.key.toString() } ?: return null + val timestamp = currentMessages.getOrNull(row.messageIndex)?.timestamp + ?: row.group?.let { currentMessages.getOrNull(it.finalIndex)?.timestamp } + return TranscriptBookmark(row.key,listState.firstVisibleItemScrollOffset,timestamp) + } LaunchedEffect(chatId, listState) { - snapshotFlow { - listState.layoutInfo.visibleItemsInfo.firstOrNull()?.let { - TranscriptBookmark(it.key.toString(), listState.firstVisibleItemScrollOffset) - } - }.collect { value -> - if (!restoring && value != null && value.key != "footer") TranscriptBookmarks.put(chatId, value) + snapshotFlow { if (restoring) null else currentBookmark() }.collect { value -> + if (value != null) TranscriptBookmarks.put(chatId,value) + } + } + DisposableEffect(chatId,listState) { + onDispose { + // Flush the last frame when navigation disposes the collector before it can run. + if (!restoring) currentBookmark()?.let { TranscriptBookmarks.put(chatId,it) } } } val atEnd by remember(listState) { derivedStateOf { !listState.canScrollForward } } From 87b0e4942df0b04549a3157cc889912962adeded Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:53:46 +0800 Subject: [PATCH 08/10] fix(reading): align model records and preserve split chapter identity for ry.6 --- app/build.gradle.kts | 4 +- .../operit/api/chat/EnhancedAIService.kt | 14 ++- .../core/agent/AgentProfileRepository.kt | 9 +- .../operit/core/agent/AgentRunObserver.kt | 9 ++ .../operit/core/tools/javascript/JsEngine.kt | 17 +++- .../reading/ReadingCatalogIdentity.kt | 15 +++ .../reading/ReadingCompanionAutoCommentary.kt | 2 +- .../reading/ReadingCompanionFileStore.kt | 40 +++++++- .../reading/ReadingCompanionModelGateway.kt | 31 ++++--- .../reading/ReadingCompanionService.kt | 46 +++++---- .../features/reading/ReadingCompanionStore.kt | 55 ++++++++++- .../ReadingCompanionSubagentCoordinator.kt | 75 +++++---------- .../reading/ReadingCompanionSubagentTools.kt | 19 ++-- .../features/reading/ReadingRunObserver.kt | 6 ++ .../reading/ReadingCatalogIdentityTest.kt | 33 +++++++ .../reading/ReadingCompanionFileStoreTest.kt | 34 +++++++ .../reading/ReadingCompanionSchemaV14Test.kt | 4 +- .../ReadingCompanionSubagentToolsTest.kt | 6 ++ examples/reading_companion/manifest.json | 6 +- .../packages/reading_companion.js | 8 +- .../packages/reading_companion_manage.js | 12 +-- .../packages/reading_companion_tasks.js | 8 +- .../reading_companion/ui/history_shared.js | 17 ++-- .../reading_companion/ui/reading_client.js | 36 ++++++- .../ui/reading_companion_entry/index.ui.js | 93 ++++++++++--------- .../ui/reading_companion_history/index.ui.js | 36 +++---- .../reading_companion_run_detail/index.ui.js | 67 +++++++------ 27 files changed, 469 insertions(+), 233 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCatalogIdentity.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCatalogIdentityTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 4cdaf00cd3..19eb065851 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -183,8 +183,8 @@ android { applicationId = "com.rainy.operitry" minSdk = 26 targetSdk = 34 - versionCode = 51 - versionName = "1.12.2-ry.5" + versionCode = 100205 + versionName = "1.12.2-ry.6" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt b/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt index c902acfa83..7bc285220e 100644 --- a/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt +++ b/app/src/main/java/com/ai/assistance/operit/api/chat/EnhancedAIService.kt @@ -1433,7 +1433,7 @@ class EnhancedAIService private constructor( // 使用新的Stream API AppLogger.d(TAG, "sendMessage请求前准备耗时: ${tAfterGetTools - startTime}ms, 流式输出: $stream") val requestStartTime = messageTimingNow() - notifyModelRequestStarted(chatId, isSubTask) + notifyModelRequestStarted(chatId, isSubTask, modelSnapshot) val inputUsage = beginInputUsage(serviceForFunction, requestHistory, availableTools) val responseStream = serviceForFunction.sendMessage( @@ -2031,8 +2031,14 @@ class EnhancedAIService private constructor( * report model rounds. A chat with its own run observer (the reading companion) already * persists its rounds, so it is skipped here. */ - private suspend fun notifyModelRequestStarted(chatId: String?, isSubTask: Boolean) { - com.ai.assistance.operit.core.agent.AgentRunObservers.forChat(chatId)?.onModelRequest() + private suspend fun notifyModelRequestStarted(chatId: String?, isSubTask: Boolean, snapshot: ModelExecutionSnapshot) { + val identity = com.ai.assistance.operit.api.chat.llmprovider.resolveTokenStatIdentity(snapshot.service) + com.ai.assistance.operit.core.agent.AgentRunObservers.forChat(chatId)?.onModelRequest( + com.ai.assistance.operit.core.agent.AgentModelIdentity( + snapshot.config.id, snapshot.config.name, snapshot.lease.modelIndex, + identity.first, identity.second, + ) + ) val childChatId = chatId?.takeIf { it.isNotBlank() } if (!isSubTask || childChatId == null) return if (com.ai.assistance.operit.core.agent.AgentRunObservers.hasObserver(childChatId)) return @@ -2609,7 +2615,7 @@ class EnhancedAIService private constructor( try { // 发送消息并获取响应流 val aiStartTime = messageTimingNow() - notifyModelRequestStarted(chatId, isSubTask) + notifyModelRequestStarted(chatId, isSubTask, modelSnapshot) val inputUsage = beginInputUsage(serviceForFunction, currentChatHistory, availableTools) val responseStream = serviceForFunction.sendMessage( diff --git a/app/src/main/java/com/ai/assistance/operit/core/agent/AgentProfileRepository.kt b/app/src/main/java/com/ai/assistance/operit/core/agent/AgentProfileRepository.kt index eb7ea87eef..fa8651e051 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/agent/AgentProfileRepository.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/agent/AgentProfileRepository.kt @@ -87,8 +87,8 @@ class AgentProfileRepository private constructor() { You may use only these six internal tools: reading_commentary_list_chapters (list target and four prior chapters), reading_commentary_read_chapter (read one listed chapter), - reading_commentary_get_chapter_summaries (read older persisted summaries), - reading_commentary_search (search read content and reader memories), + reading_commentary_read_file (read a scoped chapter file or summary), + reading_commentary_grep (search chapter files up to the target chapter), reading_commentary_submit_summary (stage the objective chapter summary), reading_commentary_submit_comments (submit 0-6 comments and finalize). @@ -96,8 +96,9 @@ class AgentProfileRepository private constructor() { chapterIndex, or content supplied anywhere else, including in prompts or tool arguments; the tools resolve the real target themselves. - Read the target chapter and its four immediately preceding catalog entries - before drafting. For summary-only tasks, finish with exactly one + Read the target chapter before drafting. Read preceding chapters or search + older chapter files only when needed; never use future content or untimed + reader memories as evidence. For summary-only tasks, finish with exactly one successful submit_summary call and do not call submit_comments. For commentary tasks, submit the summary first and finish with exactly one successful submit_comments call. Never call the task tool, never call any diff --git a/app/src/main/java/com/ai/assistance/operit/core/agent/AgentRunObserver.kt b/app/src/main/java/com/ai/assistance/operit/core/agent/AgentRunObserver.kt index b2515f74cb..04121e472e 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/agent/AgentRunObserver.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/agent/AgentRunObserver.kt @@ -3,11 +3,20 @@ package com.ai.assistance.operit.core.agent import com.ai.assistance.operit.data.model.AITool import java.util.concurrent.ConcurrentHashMap +data class AgentModelIdentity( + val configId: String, + val configName: String, + val modelIndex: Int, + val provider: String, + val model: String, +) + /** Narrow opt-in lifecycle for internal agents. The shared engine knows no feature databases. */ interface AgentRunObserver { val capabilityTools: Set suspend fun beforeToolBatch(tools: List) fun onModelRequest() + fun onModelRequest(identity: AgentModelIdentity) = onModelRequest() } object AgentRunObservers { private val observers = ConcurrentHashMap() diff --git a/app/src/main/java/com/ai/assistance/operit/core/tools/javascript/JsEngine.kt b/app/src/main/java/com/ai/assistance/operit/core/tools/javascript/JsEngine.kt index 0d93c51732..4d874be604 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/tools/javascript/JsEngine.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/tools/javascript/JsEngine.kt @@ -1849,7 +1849,22 @@ class JsEngine(private val context: Context) { .put("configured", persona != null) .put("roleCardId", persona?.roleCardId) .put("roleCardName", persona?.roleCardName) - .put("updatedAt", persona?.updatedAt), + .put("updatedAt", persona?.updatedAt) + .apply { + if (persona != null) { + runCatching { + kotlinx.coroutines.runBlocking(kotlinx.coroutines.Dispatchers.IO) { + com.ai.assistance.operit.features.reading.ReadingCompanionModelGateway(context) + .previewAutoCommentConfiguration(persona.roleCardId) + } + }.onSuccess { preview -> + put("configuration",JSONObject() + .put("modelSource",preview.modelSource) + .put("modelConfigName",preview.modelConfigName) + .put("model",preview.model)) + }.onFailure { put("configurationError",it.message.orEmpty()) } + } + }, ) .toString() } catch (error: Throwable) { diff --git a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCatalogIdentity.kt b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCatalogIdentity.kt new file mode 100644 index 0000000000..fe780ff03e --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCatalogIdentity.kt @@ -0,0 +1,15 @@ +package com.ai.assistance.operit.features.reading + +internal const val READING_CATALOG_SOURCES_SQL = """CREATE TABLE IF NOT EXISTS indexed_catalog_sources ( + book_id TEXT NOT NULL, chapter_index INTEGER NOT NULL, source_id TEXT NOT NULL, + PRIMARY KEY (book_id, chapter_index), + FOREIGN KEY (book_id) REFERENCES books(book_id) ON DELETE CASCADE +)""" + +internal fun staleReadingChapterIndices( + indexed: Set, + previous: Map, + expected: Map, +): Set = indexed.filterTo(mutableSetOf()) { + previous[it] == null || previous[it] != expected[it] +} diff --git a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionAutoCommentary.kt b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionAutoCommentary.kt index f890699f57..4fc190e4fb 100644 --- a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionAutoCommentary.kt +++ b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionAutoCommentary.kt @@ -1258,7 +1258,7 @@ class ReadingCompanionAutoCommentary private constructor( put("previousContextChapterLimit", 4) put("previousContextCharacterLimit", JSONObject.NULL) put("roleCardPolicy", "per_book_selected_character_card") - put("modelPolicy", "caller_model_or_global_chat") + put("modelPolicy", "caller_model_or_character_card_or_global_chat") put("tokenStatsCategory", TokenStatCategory.READING_COMPANION.name) put( "historyStorage", diff --git a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionFileStore.kt b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionFileStore.kt index 064bd66ddf..c1ae3a57ae 100644 --- a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionFileStore.kt +++ b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionFileStore.kt @@ -921,6 +921,7 @@ class ReadingCompanionFileStore( bookId: String, offset: Int = 0, limit: Int = 50, + throughChapterIndex: Int? = null, ): JSONObject = withFileStoreLock { val safeOffset = offset.coerceAtLeast(0) val safeLimit = limit.coerceIn(1, 100) @@ -935,6 +936,10 @@ class ReadingCompanionFileStore( chapterNumber: Int? = null, chapterTitle: String? = null, ) { + // Untimed book/character/companion documents can contain later plot knowledge. + if (throughChapterIndex != null && + (kind != "chapter" || chapterNumber == null || chapterNumber > throughChapterIndex + 1 || + file.name !in setOf(CONTENT_FILE_NAME,"summary.md"))) return val canonical = runCatching { file.canonicalFile }.getOrNull() ?: return if ( !canonical.isFile || @@ -1187,15 +1192,17 @@ class ReadingCompanionFileStore( } /** Literal lookup over the same book-bound active files exposed by the reader. */ - fun grepPersistedFiles(bookId: String, query: String, offset: Int = 0, limit: Int = 30): JSONObject = withFileStoreLock { + fun grepPersistedFiles(bookId: String, query: String, offset: Int = 0, limit: Int = 30, + throughChapterIndex: Int? = null): JSONObject = withFileStoreLock { require(query.isNotBlank()) { "query must not be blank" } require(offset >= 0 && limit in 1..100) { "Invalid grep page" } val matches = JSONArray() var seen = 0 var fileOffset = 0 var more = false + var resultCharacters = 0 search@ while (true) { - val page = listPersistedFilesFromCatalogs(bookId, fileOffset, 100) + val page = listPersistedFilesFromCatalogs(bookId, fileOffset, 100, throughChapterIndex) val entries = page.getJSONArray("entries") for (i in 0 until entries.length()) { val entry = entries.getJSONObject(i) @@ -1224,10 +1231,19 @@ class ReadingCompanionFileStore( if (matchIndex >= 0) { if (seen++ >= offset) { if (matches.length() == limit) { more = true; break } - matches.put(JSONObject().put("path", file.absolutePath) + val hit = JSONObject().put("path", file.absolutePath) .put("kind", entry.optString("kind")) .put("lineNumber", line).put("offset", characterOffset + matchIndex) - .put("text", text.substring(maxOf(0, matchIndex - 200), minOf(text.length, matchIndex + 800)))) + .put("text", text.substring(maxOf(0, matchIndex - 200), minOf(text.length, matchIndex + 800))) + val size = hit.toString().sumOf { char -> when (char) { + '"' -> 6; '&' -> 5; '<','>' -> 4; '\'' -> 6; else -> 1 + } } + if (resultCharacters + size > 24_000 && matches.length() > 0) { + more = true + break + } + matches.put(hit) + resultCharacters += size } } characterOffset += consumed @@ -1248,6 +1264,7 @@ class ReadingCompanionFileStore( path: String, offset: Int = 0, maxCharacters: Int? = null, + throughChapterIndex: Int? = null, ): JSONObject = withFileStoreLock { val requested = path.trim() require(requested.isNotBlank()) { "文件路径不能为空" } @@ -1277,6 +1294,21 @@ class ReadingCompanionFileStore( require(isActivePersistedPathLocked(bookId, target)) { "文件不属于当前书籍目录或当前章节 catalog" } + if (throughChapterIndex != null) { + var pageOffset = 0 + var allowed = false + do { + val page = listPersistedFilesFromCatalogs(bookId,pageOffset,100,throughChapterIndex) + val entries = page.getJSONArray("entries") + allowed = (0 until entries.length()).any { + entries.getJSONObject(it).getString("path") == target.path + } + val next = if (page.isNull("nextOffset")) null else page.getInt("nextOffset") + if (allowed || next == null) break + pageOffset = next + } while (true) + require(allowed) { "This task can read only chapter text and summaries through its target chapter; future and untimed memory files are excluded." } + } if (target.name in setOf(CONTENT_FILE_NAME, "summary.md", "comments.json", META_FILE_NAME)) { recoverSummaryPublication(requireNotNull(target.parentFile)) } diff --git a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionModelGateway.kt b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionModelGateway.kt index 0818db1907..f3769e103a 100644 --- a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionModelGateway.kt +++ b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionModelGateway.kt @@ -76,6 +76,15 @@ data class ResolvedAutoCommentRole( val name: String, ) +internal data class AutoCommentRequestContext( + val roleCardId: String, + val roleCardName: String, + val rolePrompt: String, + val modelConfigId: String?, + val modelIndex: Int?, + val modelSource: String, +) + class AutoCommentModelTimeoutException( message: String, cause: Throwable, @@ -627,18 +636,19 @@ class ReadingCompanionModelGateway( suspend fun previewAutoCommentConfiguration( roleCardId: String, ): AutoCommentConfigurationPreview { - val role = resolveAutoCommentRole(roleCardId) + val request = resolveAutoCommentRequestContext(roleCardId, null) val host = EnhancedAIService.getChatInstance(appContext, INTERNAL_CHAT_ID) - val lease = host.acquireAIServiceLeaseForFunction(functionType = FunctionType.CHAT) + val lease = host.acquireAIServiceLeaseForFunction(functionType = FunctionType.CHAT, + chatModelConfigIdOverride = request.modelConfigId, chatModelIndexOverride = request.modelIndex) try { val config = lease.modelConfig val providerType = ApiProviderType.fromProviderTypeId(config.apiProviderTypeId) ?: config.apiProviderType return AutoCommentConfigurationPreview( - roleCardId = role.id, - roleCardName = role.name, - modelSource = MODEL_SOURCE_GLOBAL_CHAT, + roleCardId = request.roleCardId, + roleCardName = request.roleCardName, + modelSource = request.modelSource, modelConfigId = config.id, modelConfigName = config.name, modelIndex = lease.modelIndex, @@ -651,7 +661,7 @@ class ReadingCompanionModelGateway( } } - private suspend fun resolveAutoCommentRequestContext( + internal suspend fun resolveAutoCommentRequestContext( roleCardId: String, runtime: ToolExecutionManager.ToolRuntimeContext?, ): AutoCommentRequestContext { @@ -714,15 +724,6 @@ class ReadingCompanionModelGateway( } private companion object { - data class AutoCommentRequestContext( - val roleCardId: String, - val roleCardName: String, - val rolePrompt: String, - val modelConfigId: String?, - val modelIndex: Int?, - val modelSource: String, - ) - data class PreparedAutoCommentPrompt( val prompt: String, val metrics: AutoCommentPromptMetrics, diff --git a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionService.kt b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionService.kt index 2c11096158..7bcda57a66 100644 --- a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionService.kt +++ b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionService.kt @@ -139,7 +139,7 @@ class ReadingCompanionService private constructor( val refreshed = withTimeoutOrNull(SUMMARY_LIST_CATALOG_REFRESH_BUDGET_MS) { val state = selectedReadingState() val chapters = provider.getChapters(state.book.id) - fileStore.syncBookCatalog(state.book, chapters) + synchronizeCatalog(fileStore, state, chapters) store.setLastResolvedBookId(state.book.id) fileStore.listSummaryFiles(state.book.id) } @@ -186,7 +186,7 @@ class ReadingCompanionService private constructor( val refreshed = withTimeoutOrNull(LIST_FILES_CATALOG_REFRESH_BUDGET_MS) { val state = selectedReadingState() val chapters = provider.getChapters(state.book.id) - fileStore.syncBookCatalog(state.book, chapters) + synchronizeCatalog(fileStore, state, chapters) store.setLastResolvedBookId(state.book.id) fileStore.listPersistedFiles( book = state.book, @@ -241,7 +241,7 @@ class ReadingCompanionService private constructor( val refreshedBookId = withTimeoutOrNull(READ_FILE_CATALOG_REFRESH_BUDGET_MS) { val state = selectedReadingState() val chapters = provider.getChapters(state.book.id) - fileStore.syncBookCatalog(state.book, chapters) + synchronizeCatalog(fileStore, state, chapters) store.setLastResolvedBookId(state.book.id) state.book.id } @@ -270,7 +270,7 @@ class ReadingCompanionService private constructor( val state = selectedReadingState() val chapters = provider.getChapters(state.book.id) val fileStore = ReadingCompanionFileStore(appContext, publicationSnapshot = store.publicationSnapshot()) - fileStore.syncBookCatalog(state.book, chapters) + synchronizeCatalog(fileStore, state, chapters) val roleCardId = callerRoleCardId?.trim()?.takeIf(String::isNotBlank) return JSONObject() .put("book", state.book.name) @@ -365,7 +365,7 @@ class ReadingCompanionService private constructor( "上下文组装期间前文章节目录发生变化" } } - fileStore.syncBookCatalog(state.book, chapters) + synchronizeCatalog(fileStore, state, chapters) // The context call itself is a valid first processing pass (for example, before the // incremental worker has run). Persist only the already-safe current prefix after the // sourceId/boundary checks above; previous chapters remain read-only provider context. @@ -804,7 +804,7 @@ class ReadingCompanionService private constructor( val state = selectedReadingState(bookId) val chapters = provider.getChapters(bookId) val files = ReadingCompanionFileStore(appContext, publicationSnapshot = store.publicationSnapshot()) - files.syncBookCatalog(state.book, chapters) + synchronizeCatalog(files, state, chapters) synchronizeBoundary(state) val old = chapters.filter { it.index < state.chapterIndex }.sortedBy { it.index } cacheDownloadedChapterSnapshots(old, @@ -830,7 +830,7 @@ class ReadingCompanionService private constructor( val state = selectedReadingState(bookId) val chapters = provider.getChapters(state.book.id) val fileStore = ReadingCompanionFileStore(appContext, publicationSnapshot = store.publicationSnapshot()) - fileStore.syncBookCatalog(state.book, chapters) + synchronizeCatalog(fileStore, state, chapters) synchronizeBoundary(state) var indexed = 0 @@ -883,9 +883,11 @@ class ReadingCompanionService private constructor( repeat(summaries.length()) { index -> val item = summaries.getJSONObject(index) val chapterIndex = item.getInt("chapterNumber") - 1 - if (chapterIndex <= state.chapterIndex) { + val chapter = chapters.firstOrNull { it.index == chapterIndex && !it.isVolume } + if (chapterIndex <= state.chapterIndex && chapter != null && + item.optString("chapterRef") == ReadingCompanionFileStore.chapterRef(state.book.id, chapter.sourceId)) { val text = item.getString("summary") - store.indexPublishedSummary(state.book.id, chapterIndex, PublishedSummary( + store.indexPublishedSummary(state.book.id, chapterIndex, chapter.sourceId, PublishedSummary( text, item.optString("contentHash"), item.optString("contentHashKind"), ReadingCompanionFileStore.contentHash(text), "file", false, )) @@ -932,7 +934,7 @@ class ReadingCompanionService private constructor( val targetChapter = chapters.firstOrNull { it.index == targetIndex } ?: error("目标章节已不在目录中") val fileStore = ReadingCompanionFileStore(appContext, publicationSnapshot = store.publicationSnapshot()) - fileStore.syncBookCatalog(state.book, chapters) + synchronizeCatalog(fileStore, state, chapters) readFreshFileSummary( state = state, chapter = targetChapter, @@ -1064,7 +1066,7 @@ class ReadingCompanionService private constructor( val chapters = provider.getChapters(state.book.id) .sortedByDescending(ReaderChapter::index) val fileStore = ReadingCompanionFileStore(appContext, publicationSnapshot = store.publicationSnapshot()) - fileStore.syncBookCatalog(state.book, chapters) + synchronizeCatalog(fileStore, state, chapters) val rangeStart = startChapterIndex ?: 0 val rangeEnd = endChapterIndex ?: state.chapterIndex @@ -1455,7 +1457,7 @@ class ReadingCompanionService private constructor( // Keep the summary discoverable by the existing FTS-backed search/knowledge APIs // without invoking the legacy direct model gateway or fabricating structured facts. store.replaceChapter(content) - store.indexPublishedSummary(state.book.id, chapter.index, published) + store.indexPublishedSummary(state.book.id, chapter.index, chapter.sourceId, published) store.finishAutoCommentRun( runId = runId, status = ReadingCompanionStore.AUTO_COMMENT_RUN_STATUS_GENERATED, @@ -1537,7 +1539,7 @@ class ReadingCompanionService private constructor( val fileStore = ReadingCompanionFileStore(appContext, publicationSnapshot = store.publicationSnapshot()) // Catalog sync is deliberately the only write here. It records the currently active // source identities so file evidence cannot walk stale source directories. - fileStore.syncBookCatalog(state.book, chapters) + synchronizeCatalog(fileStore, state, chapters) val evidence = fileStore.findCharacterEvidence( bookId = state.book.id, chapters = chapters, @@ -1803,7 +1805,7 @@ class ReadingCompanionService private constructor( ): List { val chapters = chaptersOverride ?: provider.getChapters(state.book.id) val fileStore = fileStoreOverride ?: ReadingCompanionFileStore(appContext, publicationSnapshot = store.publicationSnapshot()) - fileStore.syncBookCatalog(state.book, chapters) + synchronizeCatalog(fileStore, state, chapters) val candidates = chapters .asSequence() .filter { chapter -> @@ -1892,7 +1894,7 @@ class ReadingCompanionService private constructor( val chapters = provider.getChapters(state.book.id) val chapterByIndex = chapters.associateBy(ReaderChapter::index) val fileStore = ReadingCompanionFileStore(appContext, publicationSnapshot = store.publicationSnapshot()) - fileStore.syncBookCatalog(state.book, chapters) + synchronizeCatalog(fileStore, state, chapters) val verifiedTextIndices = mutableSetOf() val verifiedKnowledgeIndices = mutableSetOf() withTimeoutOrNull(SUMMARY_CONTEXT_FRESHNESS_BUDGET_MS) { @@ -1965,7 +1967,7 @@ class ReadingCompanionService private constructor( if (safeLimit == 0) return emptyList() val chapters = provider.getChapters(state.book.id) val fileStore = ReadingCompanionFileStore(appContext, publicationSnapshot = store.publicationSnapshot()) - fileStore.syncBookCatalog(state.book, chapters) + synchronizeCatalog(fileStore, state, chapters) val sourceByIndex = mutableMapOf() val contractByIndex = mutableMapOf() val result = mutableListOf() @@ -2106,7 +2108,7 @@ class ReadingCompanionService private constructor( ) ) { "章节摘要生成期间阅读边界发生变化" } val currentChapters = provider.getChapters(initialState.book.id) - fileStore.syncBookCatalog(initialState.book, currentChapters) + synchronizeCatalog(fileStore, initialState, currentChapters) val currentChapter = currentChapters.firstOrNull { it.index == generatedFrom.chapterIndex } ?: error("摘要生成期间目标章节已从目录中移除") @@ -2151,6 +2153,16 @@ class ReadingCompanionService private constructor( store.prepareForState(state) } + private fun synchronizeCatalog( + fileStore: ReadingCompanionFileStore, + state: ReadingState, + chapters: List, + ) { + fileStore.syncBookCatalog(state.book, chapters) + store.updateBook(state) + store.synchronizeCatalogSources(state.book.id, chapters) + } + private fun failIfBoundaryRegressed(previous: ReadingState, latest: ReadingState) { if (!ReadingBoundaryGuard.hasRegressed(previous, latest)) return synchronizeBoundary(latest) diff --git a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionStore.kt b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionStore.kt index 8bdcad41f0..c5c2df3c22 100644 --- a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionStore.kt +++ b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionStore.kt @@ -543,6 +543,7 @@ class ReadingCompanionStore(context: Context) : createKnowledgeTables(db) createAutoCommentTables(db) createTaskTables(db) + createCatalogSourcesTable(db) } override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) { @@ -707,6 +708,46 @@ class ReadingCompanionStore(context: Context) : ) } if (oldVersion < 15) createTaskTables(db) + if (oldVersion < 16) createCatalogSourcesTable(db) + } + + private fun createCatalogSourcesTable(db: SQLiteDatabase) { + db.execSQL(READING_CATALOG_SOURCES_SQL) + } + + /** Derived text/knowledge must not survive a catalog entry being replaced at the same index. */ + @Synchronized + fun synchronizeCatalogSources(bookId: String, chapters: List) { + val expected = chapters.filterNot { it.isVolume }.associate { it.index to it.sourceId } + val db = writableDatabase + db.beginTransaction() + try { + val previous = mutableMapOf() + db.rawQuery("SELECT chapter_index, source_id FROM indexed_catalog_sources WHERE book_id = ?", + arrayOf(bookId)).use { cursor -> + while (cursor.moveToNext()) previous[cursor.getInt(0)] = cursor.getString(1) + } + val indexed = mutableSetOf() + db.rawQuery("""SELECT chapter_index FROM chapters WHERE book_id = ? + UNION SELECT chapter_index FROM chapter_knowledge WHERE book_id = ?""", + arrayOf(bookId, bookId)).use { cursor -> + while (cursor.moveToNext()) indexed.add(cursor.getInt(0)) + } + staleReadingChapterIndices(indexed, previous, expected) + .forEach { deleteChapter(db, bookId, it) } + (previous.keys - expected.keys).forEach { + db.delete("indexed_catalog_sources", "book_id = ? AND chapter_index = ?", + arrayOf(bookId, it.toString())) + } + expected.filter { (index, source) -> previous[index] != source }.forEach { (index, source) -> + db.insertWithOnConflict("indexed_catalog_sources", null, ContentValues().apply { + put("book_id", bookId); put("chapter_index", index); put("source_id", source) + }, SQLiteDatabase.CONFLICT_REPLACE) + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } } private fun createTaskTables(db: SQLiteDatabase) { @@ -3163,6 +3204,7 @@ class ReadingCompanionStore(context: Context) : val contentHash = ReadingTextIndexSupport.sha256(content.content) db.beginTransaction() try { + requireCatalogSource(db, content.bookId, content.chapterIndex, content.sourceId) val indexedChapter = getIndexedChapter(db, content.bookId, content.chapterIndex) val storedChunks = getStoredChunks(db, content.bookId, content.chapterIndex) val unchanged = indexedChapter?.contentHash == contentHash && @@ -3239,8 +3281,9 @@ class ReadingCompanionStore(context: Context) : /** Index the actual durable summary, never the discarded model candidate. */ @Synchronized - fun indexPublishedSummary(bookId: String, chapterIndex: Int, summary: PublishedSummary) { + fun indexPublishedSummary(bookId: String, chapterIndex: Int, sourceId: String, summary: PublishedSummary) { val db = writableDatabase + requireCatalogSource(db, bookId, chapterIndex, sourceId) val indexed = getIndexedChapter(db, bookId, chapterIndex) ?: return if (summary.sourceHashKind != ReadingCompanionFileStore.CONTENT_HASH_KIND_READABLE || summary.sourceHash != indexed.contentHash || summary.text.isBlank()) { @@ -3843,6 +3886,14 @@ class ReadingCompanionStore(context: Context) : ) } + private fun requireCatalogSource(db: SQLiteDatabase, bookId: String, index: Int, sourceId: String) { + val current = db.rawQuery( + "SELECT source_id FROM indexed_catalog_sources WHERE book_id = ? AND chapter_index = ?", + arrayOf(bookId, index.toString()), + ).use { if (it.moveToFirst()) it.getString(0) else null } + check(current == sourceId) { "章节目录已更新,请重新读取正文" } + } + private fun deleteChapter(db: SQLiteDatabase, bookId: String, chapterIndex: Int) { db.delete( "text_chunks", @@ -3866,7 +3917,7 @@ class ReadingCompanionStore(context: Context) : companion object { private const val DATABASE_NAME = "reading_companion.db" - internal const val DATABASE_VERSION = 15 + internal const val DATABASE_VERSION = 16 private const val SELECTED_BOOK_KEY = "selected_book_id" private const val LAST_RESOLVED_BOOK_KEY = "last_resolved_book_id" private const val SUMMARY_BATCH_PREFS_KEY_PREFIX = "summary_batch|" diff --git a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionSubagentCoordinator.kt b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionSubagentCoordinator.kt index baf8640470..af186676dd 100644 --- a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionSubagentCoordinator.kt +++ b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionSubagentCoordinator.kt @@ -9,11 +9,9 @@ import com.ai.assistance.operit.core.agent.SubagentTaskResult import com.ai.assistance.operit.data.db.AppDatabase import com.ai.assistance.operit.data.model.ChatEntity import com.ai.assistance.operit.data.model.FunctionType -import com.ai.assistance.operit.data.preferences.FunctionalConfigManager import com.ai.assistance.operit.data.repository.ChatHistoryManager import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.first import kotlinx.coroutines.isActive import kotlinx.coroutines.launch @@ -39,7 +37,6 @@ class ReadingCompanionSubagentCoordinator private constructor(context: Context) private val store = ReadingCompanionStore(appContext) private val chatHistoryManager = ChatHistoryManager.getInstance(appContext) private val subagentCoordinator = SubagentCoordinator.getInstance(appContext) - private val functionalConfigManager = FunctionalConfigManager(appContext) /** * 执行一次段评审计子代理运行。 @@ -65,6 +62,13 @@ class ReadingCompanionSubagentCoordinator private constructor(context: Context) summaryOnly: Boolean = false, ): SubagentGenerationOutcome { val conversation = trigger == ReadingCompanionAudit.TRIGGER_CONVERSATION + val modelRequest = if (summaryOnly) { + AutoCommentRequestContext(persona.roleCardId,persona.roleCardName,"", + runtime?.parentModelConfigId, runtime?.parentModelIndex, + if (runtime?.parentModelConfigId.isNullOrBlank()) "global_chat" else "caller_chat") + } else ReadingCompanionModelGateway(appContext).resolveAutoCommentRequestContext( + persona.roleCardId, runtime.takeIf { conversation }) + var actualExecution: AutoCommentModelExecution? = null val parentChatId = if (conversation) { requireNotNull(runtime?.callerChatId?.takeIf(String::isNotBlank)) { @@ -135,12 +139,11 @@ class ReadingCompanionSubagentCoordinator private constructor(context: Context) subagentType = ReadingCompanionAudit.PROFILE_ID, // 阶段 3 恢复语义:绝不复用旧 taskId,每次全新 child + run。 taskId = null, - parentModelConfigId = requestParentModelConfigId(conversation, runtime), - parentModelIndex = - if (conversation) runtime?.parentModelIndex else null, + parentModelConfigId = modelRequest.modelConfigId, + parentModelIndex = modelRequest.modelIndex, functionType = FunctionType.CHAT, toolsEnabled = true, - isolatedToolPrompts = ReadingCompanionSubagentTools.prompts(), + isolatedToolPrompts = ReadingCompanionSubagentTools.prompts(summaryOnly), terminalToolNames = ReadingCompanionSubagentTools.TERMINAL_TOOL_NAMES, promptHooksEnabled = false, childHidden = !conversation, @@ -157,7 +160,15 @@ class ReadingCompanionSubagentCoordinator private constructor(context: Context) childChatId = createdRun.childChatId, ) com.ai.assistance.operit.core.agent.AgentRunObservers.register( - createdRun.childChatId, ReadingRunObserver(appContext, session, createdRun.childChatId), + createdRun.childChatId, ReadingRunObserver(appContext, session, createdRun.childChatId) { identity -> + val execution = AutoCommentModelExecution( + identity.configId,identity.configName,identity.modelIndex,modelRequest.modelSource, + identity.provider,identity.model,persona.roleCardId,persona.roleCardName, + ) + // Capture the leased request identity before network IO, including failed requests. + actualExecution = execution + store.updateAutoCommentRunExecution(runId,execution) + }, ) ReadingCompanionSubagentSessionRegistry.register( createdRun.childChatId, @@ -224,14 +235,7 @@ class ReadingCompanionSubagentCoordinator private constructor(context: Context) "段评子代理未依次成功调用 submit_summary 与 submit_comments,未发布任何结果" } } - val execution = - resolveExecution( - conversation, - runtime, - executedRun.modelConfigIdSnapshot, - executedRun.modelIndexSnapshot, - persona, - ) + val execution = checkNotNull(actualExecution) { "Model execution identity was not recorded" } SubagentGenerationOutcome( comments = session.candidateDrafts, summary = session.candidateSummary, @@ -277,43 +281,6 @@ class ReadingCompanionSubagentCoordinator private constructor(context: Context) compareBy { it.createdAt }.thenBy { it.id } ) - private fun requestParentModelConfigId( - conversation: Boolean, - runtime: ToolExecutionManager.ToolRuntimeContext?, - ): String? = if (conversation) runtime?.parentModelConfigId else null - - private suspend fun resolveExecution( - conversation: Boolean, - runtime: ToolExecutionManager.ToolRuntimeContext?, - modelConfigIdSnapshot: String?, - modelIndexSnapshot: Int?, - persona: AutoCommentPersona, - ): AutoCommentModelExecution { - val configId = - modelConfigIdSnapshot - ?: functionalConfigManager - .getConfigMappingForFunction(FunctionType.CHAT) - .configId - val modelIndex = modelIndexSnapshot ?: runtime?.parentModelIndex ?: 0 - val config = - runCatching { - com.ai.assistance.operit.data.preferences.ModelConfigManager(appContext) - .getModelConfigFlow(configId) - .first() - } - .getOrNull() - return AutoCommentModelExecution( - configId = configId, - configName = config?.name.orEmpty(), - modelIndex = modelIndex.coerceAtLeast(0), - modelSource = if (conversation) "parent_conversation" else "global_chat", - provider = config?.apiProviderType?.name.orEmpty(), - model = config?.modelName?.ifBlank { config.name }.orEmpty(), - roleCardId = persona.roleCardId, - roleCardName = persona.roleCardName, - ) - } - companion object { /** 后台/手动/对话内共用的 run/claim 心跳间隔(远小于 5 分钟 stale 窗口)。 */ private const val HEARTBEAT_TICKER_INTERVAL_MS = 60_000L @@ -394,7 +361,7 @@ class ReadingCompanionSubagentCoordinator private constructor(context: Context) } else { append("以角色卡「$roleCardName」的口吻,为小说《$bookName》即将阅读的第 ${chapterIndex + 1} 章") append("生成 0 到 6 条段落级 AI 段评,并提交一份客观章节摘要。") - append("\n阅读范围:目标章和目录中紧邻目标章的前四章;仅读取 contentAvailable=true 的章节。不可用的前文在本轮重试无法恢复,请继续读取可用章节,不得编造缺失的前文。") + append("\n阅读范围:必须完整读取目标章;只有理解人物、事件或核对呼应确有需要时,才读取目录中紧邻目标章的前四章或检索更早正文。不要为了凑流程通读所有前文。仅读取 contentAvailable=true 的章节;不可用的前文在本轮重试无法恢复,不得编造。检索仅限目标章及以前,不可使用无时间版本的读者记忆推断剧情。") } } diff --git a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionSubagentTools.kt b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionSubagentTools.kt index 00d5b8ca4c..973debc744 100644 --- a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionSubagentTools.kt +++ b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingCompanionSubagentTools.kt @@ -56,7 +56,7 @@ object ReadingCompanionSubagentTools { * 6 个工具的 isolated 提示词(同一来源:ToolRegistration 注册、SubagentCoordinator * 透传、测试断言)。 */ - fun prompts(): List = + fun prompts(summaryOnly: Boolean = false): List = listOf( ToolPrompt( name = TOOL_LIST_CHAPTERS, @@ -68,7 +68,7 @@ object ReadingCompanionSubagentTools { ToolPrompt( name = TOOL_READ_CHAPTER, description = - "Read one chapter from the required five-chapter window using chapterRef. " + + "Read the target chapter, or preceding context only when needed, using chapterRef. " + "The target chapter includes paragraph anchor ids; previous chapters are " + "context only. Follow nextOffset until hasMore is false to read the whole chapter. " + "Offsets address the returned labeled text; paragraph anchors may continue across pages.", @@ -94,7 +94,7 @@ object ReadingCompanionSubagentTools { ToolPrompt( name = TOOL_GREP, description = - "Grep literal text in this task book directory. Return paths, offsets and " + + "Grep literal text and summaries through the target chapter only; future chapters and untimed memory are excluded. Return paths, offsets and " + "line numbers, then read original text to verify facts for the " + "commentary. Never invents facts beyond the returned evidence.", parametersStructured = @@ -151,7 +151,13 @@ object ReadingCompanionSubagentTools { ), ), ), - ) + ).filterNot { summaryOnly && it.name == TOOL_SUBMIT_COMMENTS }.map { + if (summaryOnly && it.name == TOOL_SUBMIT_SUMMARY) + it.copy(description = "Submit the factual target-chapter summary. Usually 100-200 Chinese characters, " + + "up to about 300 for dense chapters. Successful submission ends the task immediately; " + + "a validation error allows correction. Do not call other tools in the same batch.") + else it + } /** ToolRegistration 使用的统一执行器入口。 */ fun execute(tool: AITool): ToolResult { @@ -443,6 +449,7 @@ object ReadingCompanionSubagentTools { } private fun submitComments(tool: AITool, session: ReadingCompanionRunSession): ToolResult { + if (session.summaryOnly) return failure(tool,"This is a summary-only task; use submit_summary to finish.") if (session.candidateSummary.isBlank()) { return ToolResult( toolName = tool.name, @@ -629,9 +636,9 @@ class ProductionReadingCompanionSubagentBackend( store.incrementRunModelRound(runId) override fun readFile(bookId: String, path: String, offset: Int, maxCharacters: Int): JSONObject = - fileStore.readPersistedFile(bookId, path, offset, maxCharacters) + fileStore.readPersistedFile(bookId, path, offset, maxCharacters, throughChapterIndex = targetChapterIndex) override suspend fun grep(bookId: String, query: String, offset: Int, limit: Int): JSONObject = - fileStore.grepPersistedFiles(bookId, query, offset, limit) + fileStore.grepPersistedFiles(bookId, query, offset, limit, throughChapterIndex = targetChapterIndex) .put("coverage", fileStore.chapterCacheCoverage(bookId, targetChapterIndex)) } diff --git a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingRunObserver.kt b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingRunObserver.kt index 90641713f6..0259c34bb9 100644 --- a/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingRunObserver.kt +++ b/app/src/main/java/com/ai/assistance/operit/features/reading/ReadingRunObserver.kt @@ -9,6 +9,7 @@ internal class ReadingRunObserver( private val context: Context, private val session: ReadingCompanionRunSession, private val childChatId: String, + private val onExecution: (com.ai.assistance.operit.core.agent.AgentModelIdentity) -> Unit = {}, ) : AgentRunObserver { override val capabilityTools = ReadingCompanionSubagentTools.CAPABILITY_BOUND_NAMES override suspend fun beforeToolBatch(tools: List) { @@ -33,4 +34,9 @@ internal class ReadingRunObserver( runCatching { SubagentRunRepository.getInstance(context).incrementModelRoundCountByChildChatId(childChatId) } } } + override fun onModelRequest(identity: com.ai.assistance.operit.core.agent.AgentModelIdentity) { + checkActive() + onExecution(identity) + onModelRequest() + } } diff --git a/app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCatalogIdentityTest.kt b/app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCatalogIdentityTest.kt new file mode 100644 index 0000000000..9913cde03b --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCatalogIdentityTest.kt @@ -0,0 +1,33 @@ +package com.ai.assistance.operit.features.reading + +import java.sql.DriverManager +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReadingCatalogIdentityTest { + @Test fun `changed removed and legacy entries invalidate but unchanged entries remain`() { + assertEquals(setOf(1, 2, 4), staleReadingChapterIndices( + setOf(1, 2, 3, 4), mapOf(1 to "old", 2 to "removed", 3 to "same"), + mapOf(1 to "new-byte-range", 3 to "same", 4 to "legacy"), + )) + } + + @Test fun `catalog identity migration preserves existing data and isolates books`() { + DriverManager.getConnection("jdbc:sqlite::memory:").use { db -> + db.createStatement().use { sql -> + sql.execute("PRAGMA foreign_keys=ON") + sql.execute("CREATE TABLE books(book_id TEXT PRIMARY KEY)") + sql.execute("INSERT INTO books VALUES('a'),('b')") + sql.execute(READING_CATALOG_SOURCES_SQL) + sql.execute(READING_CATALOG_SOURCES_SQL) + sql.execute("INSERT INTO indexed_catalog_sources VALUES('a',0,'range-a'),('b',0,'range-b')") + sql.execute("DELETE FROM books WHERE book_id='a'") + sql.executeQuery("SELECT book_id,source_id FROM indexed_catalog_sources").use { + it.next() + assertEquals("b", it.getString(1)) + assertEquals("range-b", it.getString(2)) + } + } + } + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCompanionFileStoreTest.kt b/app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCompanionFileStoreTest.kt index cd0e1fab0a..76cf6a45d5 100644 --- a/app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCompanionFileStoreTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCompanionFileStoreTest.kt @@ -15,6 +15,40 @@ import org.junit.Test import org.mockito.Mockito.mock class ReadingCompanionFileStoreTest { + @Test fun `generation search and reads exclude later chapters and untimed memory`() { + val future = chapter.copy(index=1,sourceId="future") + store.syncBookCatalog(book,listOf(chapter,future)) + store.writeChapterContent(book,chapter,"needle earlier") + store.writeChapterContent(book,future,"needle future") + val all = store.grepPersistedFiles(book.id,"needle").getJSONArray("results") + val scoped = store.grepPersistedFiles(book.id,"needle",throughChapterIndex=0).getJSONArray("results") + assertEquals(2,all.length()) + assertEquals(1,scoped.length()) + val later = (0 until all.length()).map { all.getJSONObject(it) }.first { + it.getString("text").contains("future") + } + assertTrue(runCatching { store.readPersistedFile(book.id,later.getString("path"),throughChapterIndex=0) }.isFailure) + val memory = store.ensureCompanionMemory(book.id,"reader") + memory.writeText("needle secret") + assertTrue(runCatching { store.readPersistedFile(book.id,memory.path,throughChapterIndex=0) }.isFailure) + } + + @Test fun `large grep results retain complete transport-safe pagination without losing matches`() { + store.writeChapterContent(book,chapter,(1..100).joinToString("\n") { "needle $it " + "\"&".repeat(450) }) + var offset = 0 + var count = 0 + do { + val page = store.grepPersistedFiles(book.id,"needle",offset,100,throughChapterIndex=0) + val xml = page.toString().replace("&","&").replace("\"",""") + assertTrue(xml.length < 64000) + count += page.getJSONArray("results").length() + val next = if (page.isNull("nextOffset")) null else page.getInt("nextOffset") + if (next == null) break + assertTrue(next > offset) + offset = next + } while (true) + assertEquals(100,count) + } private lateinit var root: File private lateinit var store: ReadingCompanionFileStore diff --git a/app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCompanionSchemaV14Test.kt b/app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCompanionSchemaV14Test.kt index 467139259d..250790da06 100644 --- a/app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCompanionSchemaV14Test.kt +++ b/app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCompanionSchemaV14Test.kt @@ -19,8 +19,8 @@ import org.junit.Test class ReadingCompanionSchemaV14Test { @Test - fun `store database version is 15 so older devices run all upgrades`() { - assertEquals(15, ReadingCompanionStore.DATABASE_VERSION) + fun `store database version is 16 so older devices run all upgrades`() { + assertEquals(16, ReadingCompanionStore.DATABASE_VERSION) // onUpgrade 必然走到 oldVersion < 13 => ensureAutoCommentRunSubagentColumns: // 该函数与测试共用 AUTO_COMMENT_RUN_V13_SUBAGENT_COLUMN_DEFINITIONS,见下。 assertTrue(AUTO_COMMENT_RUN_V13_SUBAGENT_COLUMN_DEFINITIONS.isNotEmpty()) diff --git a/app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCompanionSubagentToolsTest.kt b/app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCompanionSubagentToolsTest.kt index 9f5a761fe5..7fceb96012 100644 --- a/app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCompanionSubagentToolsTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/features/reading/ReadingCompanionSubagentToolsTest.kt @@ -16,6 +16,12 @@ import org.junit.Assert.assertTrue import org.junit.Test class ReadingCompanionSubagentToolsTest { + @Test fun `summary task exposes only its tools and describes successful termination`() { + val prompts = ReadingCompanionSubagentTools.prompts(summaryOnly=true) + assertFalse(prompts.any { it.name == ReadingCompanionSubagentTools.TOOL_SUBMIT_COMMENTS }) + assertTrue(prompts.single { it.name == ReadingCompanionSubagentTools.TOOL_SUBMIT_SUMMARY } + .description.contains("ends the task")) + } @Test fun `long labeled chapters can be fully reconstructed without changing anchors`() { diff --git a/examples/reading_companion/manifest.json b/examples/reading_companion/manifest.json index e72a3dd33b..31eb3439a8 100644 --- a/examples/reading_companion/manifest.json +++ b/examples/reading_companion/manifest.json @@ -1,7 +1,7 @@ { "schema_version": 1, "toolpkg_id": "com.operit.reading_companion", - "version": "0.9.0", + "version": "0.10.0", "main": "main.js", "display_name": { "zh": "AI 阅读伴侣", @@ -35,8 +35,8 @@ "en": "AI Auto Commentary" }, "description": { - "zh": "由用户为每本书选择一个角色卡,隔离读取下一章及有限前情,一次生成少而精的角色化段评;由多轮子代理按需查阅目标章及前四章,后台使用全局 CHAT 模型,Legado 将角色卡名字显示为段评作者。开启会产生模型 Token 消耗。", - "en": "Uses a selected per-book character card, the next chapter, and bounded private context to write sparse in-character comments. A multi-turn subagent reads the target and four preceding chapters on demand using the global CHAT model in background. Legado shows the card name as author; enabling spends model tokens." + "zh": "为每本书选择角色卡,多轮子代理读取目标章,按需查前情,生成少而精的角色化段评。模型依次采用发起对话的模型、角色卡固定模型或全局 CHAT 模型;记录展示实际使用的模型。Legado 将角色卡名字显示为作者。开启会产生模型 Token 消耗。", + "en": "Selects a per-book character card and reads the target chapter, consulting earlier context only as needed. Uses the initiating conversation model, then the card's fixed model, then global CHAT; records show the model actually used. Legado displays the card name as author. Enabling spends model tokens." } }, { diff --git a/examples/reading_companion/packages/reading_companion.js b/examples/reading_companion/packages/reading_companion.js index 2648a9216f..d5a18da576 100644 --- a/examples/reading_companion/packages/reading_companion.js +++ b/examples/reading_companion/packages/reading_companion.js @@ -113,8 +113,8 @@ { "name": "chapter_number", "description": { - "zh": "章节号,从 1 开始;省略表示当前章", - "en": "One-based chapter number; omit for current chapter" + "zh": "目录序号,从 1 开始(拆分子节分别计数,不是标题中的原著章号);省略表示当前章", + "en": "One-based catalog position, counting split sections separately; omit for current chapter" }, "type": "number", "required": false @@ -214,8 +214,8 @@ { "name": "chapter_number", "description": { - "zh": "章节号,从 1 开始;省略表示当前章", - "en": "One-based chapter number; omit for current chapter" + "zh": "目录序号,从 1 开始(拆分子节分别计数,不是标题中的原著章号);省略表示当前章", + "en": "One-based catalog position, counting split sections separately; omit for current chapter" }, "type": "number", "required": false diff --git a/examples/reading_companion/packages/reading_companion_manage.js b/examples/reading_companion/packages/reading_companion_manage.js index d99cae6399..f7b62dfc34 100644 --- a/examples/reading_companion/packages/reading_companion_manage.js +++ b/examples/reading_companion/packages/reading_companion_manage.js @@ -23,16 +23,16 @@ { "name": "summary_batch_prefs", "description": { - "zh": "读取或保存当前书籍的手动摘要范围与单次预算。无参数时读取;传参时保存。章节号从 1 开始。", - "en": "Read or save the current book's manual-summary range and per-run budget. No parameters reads; supplied parameters save. Chapter numbers are one-based." + "zh": "读取或保存当前书籍的手动摘要范围与单次预算。无参数时读取;传参时保存。目录序号从 1 开始,拆分子节分别计数,不是标题中的原著章号。", + "en": "Read or save the current book's manual-summary range and per-run budget. No parameters reads; supplied parameters save. Use one-based catalog positions, counting split sections separately, not numbers in chapter titles." }, "parameters": [ {"name": "book_id", "description": {"zh": "明确指定书籍,省略时使用当前书籍", "en": "Explicit book ID; defaults to the current book"}, "type": "string", "required": false}, { "name": "start_chapter", "description": { - "zh": "起始章号", - "en": "Start chapter number" + "zh": "起始目录序号", + "en": "Start catalog position (split sections count separately)" }, "type": "number", "required": false @@ -40,8 +40,8 @@ { "name": "end_chapter", "description": { - "zh": "结束章号", - "en": "End chapter number" + "zh": "结束目录序号", + "en": "End catalog position (split sections count separately)" }, "type": "number", "required": false diff --git a/examples/reading_companion/packages/reading_companion_tasks.js b/examples/reading_companion/packages/reading_companion_tasks.js index 65f741909d..266e46a6fa 100644 --- a/examples/reading_companion/packages/reading_companion_tasks.js +++ b/examples/reading_companion/packages/reading_companion_tasks.js @@ -58,8 +58,8 @@ { "name": "start_chapter", "description": { - "zh": "起始章节号,从 1 开始", - "en": "One-based first chapter" + "zh": "起始目录序号,从 1 开始(拆分子节分别计数,不是标题中的原著章号)", + "en": "One-based first catalog position; split sections count separately" }, "type": "number", "required": false @@ -67,8 +67,8 @@ { "name": "end_chapter", "description": { - "zh": "结束章节号,从 1 开始", - "en": "One-based last chapter" + "zh": "结束目录序号,从 1 开始(拆分子节分别计数,不是标题中的原著章号)", + "en": "One-based last catalog position; split sections count separately" }, "type": "number", "required": false diff --git a/examples/reading_companion/ui/history_shared.js b/examples/reading_companion/ui/history_shared.js index 6413039332..947c26d5ad 100644 --- a/examples/reading_companion/ui/history_shared.js +++ b/examples/reading_companion/ui/history_shared.js @@ -126,13 +126,14 @@ function statusColor(status) { } function triggerLabel(trigger, english) { - return String(trigger || "").trim() === "manual" - ? english - ? "Manual" - : "手动触发" - : english - ? "After reading progress" - : "阅读进度触发"; + const labels = { + manual: ["手动段评", "Manual commentary"], + manual_summary: ["手动摘要", "Manual summary"], + conversation: ["对话发起", "From chat"], + reading_progress: ["阅读进度触发", "After reading progress"], + background: ["阅读进度触发", "After reading progress"], + }; + return (labels[String(trigger || "").trim()] || [String(trigger || "未知来源"), String(trigger || "Unknown trigger")])[english ? 1 : 0]; } function stageLabel(stage, english) { @@ -225,7 +226,7 @@ function modelSourceLabel(source, english) { character_card: "角色卡固定模型", global_chat: "全局“对话”模型", }; - return labels[String(source || "").trim()] || + return labels[source === "parent_conversation" ? "caller_chat" : String(source || "").trim()] || (english ? "Resolved at generation time" : "生成时解析"); } diff --git a/examples/reading_companion/ui/reading_client.js b/examples/reading_companion/ui/reading_client.js index e8196e408b..970491e861 100644 --- a/examples/reading_companion/ui/reading_client.js +++ b/examples/reading_companion/ui/reading_client.js @@ -56,16 +56,23 @@ function stop(key) { if (old) { old.stopped = true; if (old.timer) clearTimeout(old.timer); watchers.delete(key); } } async function watch(ctx, key, onUpdate, onError) { + return watchResource(key, () => call(ctx, "list_tasks", { limit: 50 }), + result => onUpdate(Array.isArray(result.tasks) ? result.tasks : []), onError); +} +async function watchResource(key, fetch, onUpdate, onError, keepWatching = () => true) { if (paused.has(key)) return; stop(key); const state = { stopped: false, timer: null }; watchers.set(key, state); const refresh = async () => { try { - const result = await call(ctx, "list_tasks", { limit: 50 }); - if (!state.stopped) onUpdate(Array.isArray(result.tasks) ? result.tasks : []); + const result = await fetch(); + if (!state.stopped) { + await onUpdate(result); + if (!keepWatching(result)) { stop(key); return; } + } } catch (error) { if (!state.stopped && onError) onError(error); } - if (!state.stopped) state.timer = setTimeout(refresh, 2000); + if (!state.stopped) state.timer = setTimeout(refresh, 4000); }; await refresh(); } @@ -97,6 +104,27 @@ function taskLabel(task, english) { : `${first == null ? "…" : Number(first) + 1}–${last == null ? "…" : Number(last) + 1}`; return `${task.kind === "cache" ? (english ? "Chapter cache" : "旧章缓存") : task.kind === "summary" ? (english ? "Summary" : "摘要") : (english ? "Commentary" : "段评")} · ${task.bookName || task.bookId} · ${range} · ${request.mode === "regenerate" ? (english ? "Regenerate" : "重生成") : (english ? "Fill missing" : "补缺")}`; } -module.exports = { call, unwrapToolResult, OWNERS, active, watch, stop, activate, pause, taskLabel, taskStatusLabel, taskErrors, taskProgress, +function retryChapters(task) { + if (active(task) || task.kind === "cache") return []; + const completed = new Set((task.completedChapterIndices || []).map(Number)); + const result = task.result || task.progress || {}; + const failures = [...(result.failures || []), ...(task.attempts || []).filter( + item => ["failed","interrupted","cancelled"].includes(item.status))]; + return [...new Set(failures.filter(item => item.chapterIndex != null).map(item => Number(item.chapterIndex)).filter( + index => Number.isInteger(index) && index >= 0 && !completed.has(index)))]; +} +const pendingRetries = new Map(); +function retryChapter(ctx, task, index) { + const key = `${task.task_id}_${index}`; + if (pendingRetries.has(key)) return pendingRetries.get(key); + const request = call(ctx, "start_task", {kind: task.kind, book_id: task.bookId, + mode: task.kind === "commentary" ? "regenerate" : "fill_missing", + count: 1, start_chapter: index + 1, end_chapter: index + 1, + request_id: `retry_${task.task_id}_${index}_${Date.now()}`}) + .finally(() => pendingRetries.delete(key)); + pendingRetries.set(key, request); + return request; +} +module.exports = { call, unwrapToolResult, OWNERS, active, watch, watchResource, stop, activate, pause, retryChapters, retryChapter, taskLabel, taskStatusLabel, taskErrors, taskProgress, tasks: { start: (ctx, p) => call(ctx, "start_task", p), get: (ctx, id) => call(ctx, "get_task", { task_id: id }), cancel: (ctx, id) => call(ctx, "cancel_task", { task_id: id }) } }; diff --git a/examples/reading_companion/ui/reading_companion_entry/index.ui.js b/examples/reading_companion/ui/reading_companion_entry/index.ui.js index e228d4857b..99638480ea 100644 --- a/examples/reading_companion/ui/reading_companion_entry/index.ui.js +++ b/examples/reading_companion/ui/reading_companion_entry/index.ui.js @@ -1,4 +1,5 @@ const readingTools = require("../reading_client.js"); +const historyTools = require("../history_shared.js"); const TOOL_PACKAGE = "reading_companion"; const AUTO_COMMENTARY_PACKAGE = "reading_companion_auto_commentary"; const HISTORY_ROUTE = @@ -94,25 +95,25 @@ function getText(useEnglish) { manualSummaries: "Generate summary batch", readCommentaryTitle: "Specified chapter commentary", readCommentaryHint: - "Choose 1–10 catalog chapters. The range may include unread chapters; every selected chapter is regenerated, and a successful result replaces its old commentary.", + "Choose 1–10 catalog entries, counting split sections separately, not numbers in chapter titles. Unread entries are allowed; successful generation replaces their old commentary.", aheadCommentaryTitle: "Read-ahead commentary", aheadCommentaryHint: "Choose only how many chapters to fill after the current chapter. The configured pre-generation window is the hard boundary.", aheadCountRequired: "Enter a chapter count from 1 to 10.", batchSummaryHint: - "Generates summaries for the current and already-read chapters; chapters with a fresh summary are skipped automatically.", + "Generates summaries for current and read chapters. Use Legado's catalog positions, including split sections, not numbers in chapter titles. Fresh summaries are skipped.", batchCount: "Count (required)", batchBudget: "Max to generate this run", - batchCommentStart: "Start chapter (required)", - batchCommentEnd: "End chapter (required)", + batchCommentStart: "Start catalog position (required)", + batchCommentEnd: "End catalog position (required)", readRangeRequired: "Enter both the start and end chapter.", readRangeTooLarge: "Choose no more than 10 chapters per run.", - batchStart: "Start chapter (optional)", - batchEnd: "End chapter (optional)", + batchStart: "Start catalog position (optional)", + batchEnd: "End catalog position (optional)", batchCalls: (count) => `Up to ${count} chapter subagent task(s); existing chapters are skipped and each task may use multiple model turns`, batchRange: (start, end, count) => - `Chapters ${start}–${end} · ${count} selected`, + `Catalog positions ${start}–${end} · ${count} selected`, batchDone: "Manual batch completed.", batchSuperseded: "The book or chapter list changed, so this batch stopped before generating the changed target. Refresh and try again.", @@ -149,7 +150,7 @@ function getText(useEnglish) { manage: "Open package management", regenerate: "Generate or prefill commentary now", regenerating: "Generating comments with the selected model…", - latestRun: "Latest commentary task", + latestRun: "Latest generation task", flowTitle: "Generation flow", flowStages: { reading_target: "Read current position and target chapter", @@ -271,23 +272,23 @@ function getText(useEnglish) { manualReadComments: "重新生成指定章节段评", manualSummaries: "生成摘要批次", readCommentaryTitle: "指定章节段评", - readCommentaryHint: "选择目录中的 1~10 章,可包含未读章节;所选章节都会重新生成,成功后替换旧段评。", + readCommentaryHint: "按阅读目录序号选择 1~10 节,可包含未读章节。拆分后的子节分别计数,不按标题里的原著章号;成功后替换所选章节的旧段评。", aheadCommentaryTitle: "提前生成段评", aheadCommentaryHint: "只需填写数量;从当前章之后开始补全,并严格受“提前生成章数”窗口限制。", aheadCountRequired: "请输入 1~10 的章节数量。", - batchSummaryHint: "为当前及已读章节生成摘要(已有有效摘要的自动跳过)", + batchSummaryHint: "为当前及已读章节生成摘要,已有有效摘要的跳过。请填阅读目录序号,拆分子节分别计数,不是标题里的原著章号。", batchCount: "数量(必填)", batchBudget: "本次最多生成", - batchCommentStart: "起始章节(必填)", - batchCommentEnd: "结束章节(必填)", + batchCommentStart: "起始目录序号(必填)", + batchCommentEnd: "结束目录序号(必填)", readRangeRequired: "请填写起始章节和结束章节。", readRangeTooLarge: "每次最多选择 10 章。", - batchStart: "起始章节(可选)", - batchEnd: "结束章节(可选)", + batchStart: "起始目录序号(可选)", + batchEnd: "结束目录序号(可选)", batchCalls: (count) => `最多 ${count} 个章节子代理任务;已存在的自动跳过,每个任务内部可能有多轮模型调用`, batchRange: (start, end, count) => - `第 ${start}~${end} 章 · 共选择 ${count} 章`, + `目录 ${start}~${end} · 共选择 ${count} 节`, batchDone: "手动批次已完成。", batchSuperseded: "书籍或章节目录已变化,本批次已在生成变更目标前停止;请刷新后重试。", batchSummaryDone: "手动批次已完成,范围内已无缺失摘要。", @@ -320,7 +321,7 @@ function getText(useEnglish) { manage: "打开包管理", regenerate: "立即生成/补全段评", regenerating: "正在使用所选模型生成段评…", - latestRun: "最近一次段评任务", + latestRun: "最近一次生成任务", flowTitle: "生成流程", flowStages: { reading_target: "读取当前进度与目标章节", @@ -461,13 +462,11 @@ function modelSourceLabel(text, source) { character_card: text.modelSourceCharacter, global_chat: text.modelSourceGlobal, }; - return labels[String(source || "").trim()] || text.modelSourceUnknown; + return labels[source === "parent_conversation" ? "caller_chat" : String(source || "").trim()] || text.modelSourceUnknown; } function runTriggerLabel(text, trigger) { - return String(trigger || "").trim() === "manual" - ? text.triggerManual - : text.triggerBackground; + return historyTools.triggerLabel(trigger, text.seconds === "s"); } function formatDuration(text, durationMs) { @@ -597,7 +596,14 @@ function readingCompanionEntryScreen(ctx) { const tasksState = useStateValue(ctx, "tasks", []); const pageState = useStateValue(ctx, "dashboardPage", "reading"); const page = pageState.value; - const watchTasks = () => readingTools.watch(ctx, "entry", tasksState.set, error => errorState.set(toErrorText(error))); + const watchTasks = () => readingTools.watchResource("entry", async () => { + const [tasks, history] = await Promise.all([ + readingTools.call(ctx, "list_tasks", {limit: 50}), + readingTools.call(ctx, "auto_commentary_history", {limit: 10}), + ]); + return {tasks: tasks.tasks || [], history}; + }, result => {tasksState.set(result.tasks); historyState.set(result.history);}, + error => errorState.set(toErrorText(error))); const isBusy = () => busyState.value || refreshingState.value; @@ -605,7 +611,7 @@ function readingCompanionEntryScreen(ctx) { if (refreshingState.value) return; refreshingState.set(true); const previousBookId = String(readingState.value && readingState.value.bookId || ""); - if (!resuming) loadingState.set(true); + if (!readingState.value) loadingState.set(true); errorState.set(""); try { const basicEnabled = ctx.isPackageImported @@ -616,17 +622,6 @@ function readingCompanionEntryScreen(ctx) { : false; basicEnabledState.set(basicEnabled); autoEnabledState.set(autoEnabled); - if (!resuming) { - readingState.set(null); - autoStatusState.set(null); - historyState.set(null); - auditGroupsState.set({ - groups: [], - totalRunChats: 0, - shownRunChats: 0, - }); - selectedPersonaState.set(null); - } const errors = []; if (basicEnabled) { @@ -643,7 +638,7 @@ function readingCompanionEntryScreen(ctx) { // Publish the book and its controls together; resumed screens keep same-book drafts. readingState.set(currentBook); selectedPersonaState.set(persona); - if (!resuming || previousBookId !== bookId) { + if (previousBookId !== bookId) { const savedStart = Number(prefsResult && prefsResult.startChapter); const savedEnd = Number(prefsResult && prefsResult.endChapter); const savedBudget = Number(prefsResult && prefsResult.budget); @@ -779,9 +774,6 @@ function readingCompanionEntryScreen(ctx) { return; } showCardPickerState.set(true); - if (cardsLoadedState.value) { - return; - } try { await queryCharacterCards(); } catch (error) { @@ -813,6 +805,7 @@ function readingCompanionEntryScreen(ctx) { roleCardId: String(card.id).trim(), }); selectedPersonaState.set(selected); + selectedPersonaState.set(await ctx.getReadingCompanionCommentaryCharacter(bookId)); showCardPickerState.set(false); noticeState.set(text.personaSelected); } catch (error) { @@ -1186,13 +1179,8 @@ function readingCompanionEntryScreen(ctx) { typeof historyState.value.runs[0] === "object" ? historyState.value.runs[0] : null; - const latestRun = latestRunFromStatus || latestRunFromHistory; - const commentaryConfiguration = - autoStatusState.value && - autoStatusState.value.configuration && - typeof autoStatusState.value.configuration === "object" - ? autoStatusState.value.configuration - : null; + const latestRun = latestRunFromHistory || latestRunFromStatus; + const commentaryConfiguration = selectedPersonaState.value?.configuration || null; const selectedRoleCardId = String( selectedPersonaState.value && selectedPersonaState.value.roleCardId || @@ -1403,6 +1391,19 @@ function readingCompanionEntryScreen(ctx) { ctx.UI.Text({ text: page === "reading" ? (task.kind === "cache" ? (useEnglish ? "Past chapter cache" : "旧章缓存") : readingTools.taskLabel(task, useEnglish)) : readingTools.taskLabel(task, useEnglish), style: "titleSmall" }), ctx.UI.Text({ text: `${readingTools.taskStatusLabel(task, useEnglish)} · ${readingTools.taskProgress(task, useEnglish)}` }), ...(readingTools.taskErrors(task, useEnglish) ? [ctx.UI.Text({ text: readingTools.taskErrors(task, useEnglish) })] : []), + ctx.UI.OutlinedButton({onClick: async () => { + const attempt = (task.attempts || []).filter(item => !item.archived && item.runId).slice(-1)[0]; + if (attempt) { + await ctx.setEnv(historyTools.RUN_ID_ENV_KEY,String(attempt.runId)); + await ctx.navigate(historyTools.DETAIL_ROUTE); + } else await ctx.navigate(HISTORY_ROUTE); + }},ctx.UI.Text({text: useEnglish ? "View progress" : "查看过程"})), + ...readingTools.retryChapters(task).map(index => ctx.UI.OutlinedButton({enabled: !isBusy(), onClick: async () => { + busyState.set(true); + try {await readingTools.retryChapter(ctx,task,index); await watchTasks();} + catch (error) {errorState.set(toErrorText(error));} + finally {busyState.set(false);} + }},ctx.UI.Text({text: useEnglish ? `Retry chapter ${index+1}` : `重试第 ${index+1} 章`}))), ...(readingTools.active(task) ? [ctx.UI.OutlinedButton({ onClick: async () => { try { await readingTools.tasks.cancel(ctx, task.task_id); await watchTasks(); } catch (error) { errorState.set(toErrorText(error)); } @@ -1773,7 +1774,7 @@ function readingCompanionEntryScreen(ctx) { ); } - if (page === "settings" && autoEnabledState.value && book) { + if (page === "settings" && book) { const configuredModel = commentaryConfiguration ? `${modelSourceLabel( text, @@ -1781,7 +1782,7 @@ function readingCompanionEntryScreen(ctx) { )} · ${String(commentaryConfiguration.modelConfigName || "")} / ${String( commentaryConfiguration.model || "", )}` - : text.modelSourceUnknown; + : String(selectedPersonaState.value?.configurationError || text.modelSourceUnknown); children.push( ctx.UI.Card( { diff --git a/examples/reading_companion/ui/reading_companion_history/index.ui.js b/examples/reading_companion/ui/reading_companion_history/index.ui.js index 5c73bcaf64..ad23d2e024 100644 --- a/examples/reading_companion/ui/reading_companion_history/index.ui.js +++ b/examples/reading_companion/ui/reading_companion_history/index.ui.js @@ -27,21 +27,17 @@ function historyScreen(ctx) { const [runs, setRuns] = ctx.useState("historyRuns", []); const [section, setSection] = ctx.useState("historySection", "runs"); - const watchTasks = () => readingTools.watch(ctx, "history", setTasks, error => setError(toErrorText(error))); + const watchTasks = () => readingTools.watchResource("history", async () => { + const [tasksResult, history] = await Promise.all([ + readingTools.call(ctx, "list_tasks", {limit: 50}), + callHistoryTool(ctx, "auto_commentary_history", {limit: 50}), + ]); + return {tasks: tasksResult.tasks || [], runs: history.runs || []}; + }, result => { + setTasks(result.tasks); setRuns(result.runs); setLoading(false); setError(""); + }, error => {setError(toErrorText(error)); setLoading(false);}); const load = async () => { - setLoading(true); - setError(""); - try { - const result = await callHistoryTool(ctx, "auto_commentary_history", { - limit: 50, - }); - setRuns(Array.isArray(result && result.runs) ? result.runs : []); - } catch (loadError) { - setError(toErrorText(loadError)); - } finally { - setLoading(false); - await watchTasks(); - } + await watchTasks(); }; const openRun = async (run) => { @@ -53,8 +49,8 @@ function historyScreen(ctx) { await Promise.resolve(ctx.navigate(DETAIL_ROUTE)); }; - const title = english ? "Commentary history" : "段评历史"; - const empty = english ? "No commentary task records yet." : "还没有段评任务记录。"; + const title = english ? "Generation history" : "生成历史"; + const empty = english ? "No generation records yet." : "还没有生成记录。"; const errorTitle = english ? "Could not load history" : "加载历史失败"; const refresh = english ? "Refresh" : "刷新"; @@ -105,6 +101,10 @@ function historyScreen(ctx) { UI.Text({ text: readingTools.taskLabel(task, english), style: "bodySmall" }), UI.Text({ text: readingTools.taskProgress(task, english) }), ...(readingTools.taskErrors(task, english) ? [UI.Text({ text: readingTools.taskErrors(task, english) })] : []), + ...readingTools.retryChapters(task).map(index => UI.OutlinedButton({onClick: async () => { + try {await readingTools.retryChapter(ctx,task,index); await watchTasks();} + catch(error) {setError(toErrorText(error));} + }},UI.Text({text: english ? `Retry chapter ${index+1}` : `重试第 ${index+1} 章`}))), ...(task.attempts || []).map(attempt => attempt.archived ? UI.Text({ text: `${Number(attempt.chapterIndex) + 1} · ${statusLabel(attempt.status, english)} · ${english ? "Detailed trace expired" : "详细记录已过保留期"}` }) : UI.OutlinedButton({ onClick: () => openRun(attempt) }, @@ -235,6 +235,10 @@ function historyScreen(ctx) { }, [ tabs, + UI.Row({fillMaxWidth: true, padding: 12, horizontalArrangement: "spaceBetween"}, [ + UI.Text({text: english ? "Most recent 50 records" : "最近 50 条记录", style: "bodySmall"}), + UI.OutlinedButton({onClick: watchTasks}, UI.Text({text: refresh})), + ]), UI.Box({ fillMaxWidth: true, weight: 1 }, UI.LazyColumn({ fillMaxSize: true, diff --git a/examples/reading_companion/ui/reading_companion_run_detail/index.ui.js b/examples/reading_companion/ui/reading_companion_run_detail/index.ui.js index 396f38b4ef..5b32864510 100644 --- a/examples/reading_companion/ui/reading_companion_run_detail/index.ui.js +++ b/examples/reading_companion/ui/reading_companion_run_detail/index.ui.js @@ -1,3 +1,4 @@ +const readingTools = require("../reading_client.js"); const { HISTORY_ROUTE, RUN_ID_ENV_KEY, @@ -129,25 +130,20 @@ function detailScreen(ctx) { const [error, setError] = ctx.useState("detailError", ""); const [detail, setDetail] = ctx.useState("detailValue", null); const [auditNotice, setAuditNotice] = ctx.useState("detailAuditNotice", ""); + const [showDiagnostics, setShowDiagnostics] = ctx.useState("detailDiagnostics", false); const runId = Number(ctx.getEnv(RUN_ID_ENV_KEY) || 0); - - const load = async () => { + const watchKey = `detail_${runId}`; + const watch = () => { if (!Number.isFinite(runId) || runId <= 0) { setError(english ? "No run was selected." : "没有选中段评任务。"); setLoading(false); return; } - setLoading(true); - setError(""); - try { - setDetail( - await callHistoryTool(ctx, "auto_commentary_run_detail", { runId }), - ); - } catch (loadError) { - setError(toErrorText(loadError)); - } finally { - setLoading(false); - } + return readingTools.watchResource(watchKey, + () => callHistoryTool(ctx,"auto_commentary_run_detail",{runId}), + result => {setDetail(result); setLoading(false); setError("");}, + error => {setError(toErrorText(error)); setLoading(false);}, + result => !!result?.run && !Number(result.run.finishedAt)); }; const backToHistory = async () => { @@ -168,16 +164,16 @@ function detailScreen(ctx) { } }; - const title = english ? "Commentary run detail" : "段评任务详情"; + const title = english ? "Generation detail" : "生成详情"; const children = []; - if (loading) { + if (loading && !detail) { children.push( UI.Row({ fillMaxWidth: true, padding: 16, spacing: 10, verticalAlignment: "center" }, [ UI.CircularProgressIndicator({ width: 20, height: 20, strokeWidth: 2 }), UI.Text({ text: english ? "Loading task…" : "正在加载任务…", color: colors.onSurfaceVariant }), ]), ); - } else if (error) { + } else if (error && !detail) { children.push( UI.Card({ fillMaxWidth: true, containerColor: colors.errorContainer }, UI.Column({ fillMaxWidth: true, padding: 16, spacing: 10 }, [ @@ -216,16 +212,8 @@ function detailScreen(ctx) { const modelParts = [ source, String(run.modelConfigName || "").trim(), - String(run.modelConfigId || "").trim() && - String(run.modelConfigId || "").trim() !== - String(run.modelConfigName || "").trim() - ? String(run.modelConfigId || "").trim() - : "", String(run.provider || "").trim(), String(run.model || "").trim(), - Number.isFinite(Number(run.modelIndex)) - ? `#${Number(run.modelIndex)}` - : "", ].filter(Boolean); const targetParts = [ String(run.chapterTitle || "").trim(), @@ -281,6 +269,7 @@ function detailScreen(ctx) { : (english ? "actual usage unavailable; estimate is labeled" : "实际用量不可用;仅显示预估"), ].filter(Boolean); const errorText = String(run.error || "").trim(); + if (error) children.push(UI.Text({text: error, color: colors.error})); children.push( UI.Card({ fillMaxWidth: true, containerColor: colors.primaryContainer }, UI.Column({ fillMaxWidth: true, padding: 16, spacing: 8 }, [ @@ -301,6 +290,9 @@ function detailScreen(ctx) { style: "bodySmall", color: colors.onPrimaryContainer, }), + ...(run.childChatId ? [UI.Button({onClick: openAuditChat}, + UI.Text({text: english ? "View tool conversation" : "查看工具调用对话"}))] : []), + ...(auditNotice ? [UI.Text({text: auditNotice, style: "bodySmall"})] : []), ]), ), UI.Card({ fillMaxWidth: true, containerColor: colors.surface }, @@ -368,9 +360,14 @@ function detailScreen(ctx) { children.push( UI.Card({ fillMaxWidth: true, containerColor: colors.secondaryContainer }, UI.Text({ - text: english - ? `Result: ${Number(run.commentCount || 0)} comments stored for safe unlock in Legado.` - : `结果:已保存 ${Number(run.commentCount || 0)} 条段评,按阅读进度在 Legado 安全解锁。`, + text: !Number(run.finishedAt) + ? (english ? "Generation in progress. Results have not been published." : "正在生成,结果尚未发布。") + : run.status !== "generated" + ? statusLabel(run.status, english) + : run.trigger === "manual_summary" + ? (english ? "Chapter summary saved." : "章节摘要已保存。") + : (english ? `${Number(run.commentCount || 0)} comments saved for safe unlock in Legado.` + : `已保存 ${Number(run.commentCount || 0)} 条段评,按阅读进度在 Legado 解锁。`), style: "bodyMedium", color: colors.onSecondaryContainer, padding: 16, @@ -378,7 +375,7 @@ function detailScreen(ctx) { ), ); } - children.push( + if (run.trigger !== "manual_summary") children.push( UI.Card({ fillMaxWidth: true, containerColor: colors.surface }, UI.Column({ fillMaxWidth: true, padding: 16, spacing: 10 }, [ UI.Text({ @@ -449,6 +446,10 @@ function detailScreen(ctx) { ]), ), ); + children.push(UI.OutlinedButton({onClick: () => setShowDiagnostics(!showDiagnostics)}, + UI.Text({text: showDiagnostics ? (english ? "Hide diagnostics" : "收起诊断记录") + : (english ? "Show diagnostics" : "展开诊断记录")}))); + if (showDiagnostics) { children.push( UI.Card({ fillMaxWidth: true, containerColor: colors.surface }, UI.Column({ fillMaxWidth: true, padding: 16, spacing: 10 }, [ @@ -560,9 +561,10 @@ function detailScreen(ctx) { ]), ), ); + } } - if (run) { + if (run && showDiagnostics) { children.push( UI.Card({ fillMaxWidth: true, containerColor: colors.surface }, UI.Column({ fillMaxWidth: true, padding: 16, spacing: 8 }, [ @@ -614,6 +616,8 @@ function detailScreen(ctx) { } children.push( + UI.OutlinedButton({fillMaxWidth: true, onClick: watch}, + UI.Text({text: english ? "Refresh" : "刷新"})), UI.OutlinedButton( { fillMaxWidth: true, onClick: backToHistory }, UI.Text({ text: english ? "Back to history" : "返回历史" }), @@ -623,11 +627,14 @@ function detailScreen(ctx) { return UI.Box( { fillMaxSize: true, + onResume: async () => {readingTools.activate(watchKey); await watch();}, + onPause: () => readingTools.pause(watchKey), topBarTitle: UI.Text({ text: title, maxLines: 1, overflow: "ellipsis" }), onLoad: async () => { if (!initialized) { setInitialized(true); - await load(); + readingTools.activate(watchKey); + await watch(); } }, }, From daac8315713efc9906a0495aa8ea6a081fa4eeb5 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Thu, 8 Oct 2026 07:56:03 +0800 Subject: [PATCH 09/10] feat(terminal): return managed runs for long commands and repair exit cleanup --- app/src/main/assets/packages/super_admin.js | 314 ++++++++-------- .../operit/core/tools/ToolRegistration.kt | 17 + .../core/tools/ToolResultDataClasses.kt | 15 + .../StandardTerminalCommandExecutor.kt | 39 ++ .../operit/core/tools/javascript/JsTools.kt | 7 + .../operit/core/tools/system/Terminal.kt | 6 + .../core/tools/system/TerminalTaskRegistry.kt | 126 +++++++ .../ui/permissions/ToolPermissionPolicies.kt | 2 + app/src/main/res/values-en/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../tools/system/TerminalTaskRegistryTest.kt | 110 ++++++ .../terminal/CommandSessionCloseTest.kt | 2 + docs/doc-src/package-dev/system.md | 28 +- examples/super_admin.js | 314 ++++++++-------- examples/super_admin.ts | 338 ++++++++---------- examples/types/results.d.ts | 12 + examples/types/system.d.ts | 9 + examples/types/tool-types.d.ts | 4 + terminal | 2 +- 19 files changed, 824 insertions(+), 527 deletions(-) create mode 100644 app/src/main/java/com/ai/assistance/operit/core/tools/system/TerminalTaskRegistry.kt create mode 100644 app/src/test/java/com/ai/assistance/operit/core/tools/system/TerminalTaskRegistryTest.kt diff --git a/app/src/main/assets/packages/super_admin.js b/app/src/main/assets/packages/super_admin.js index dde9f1a0af..44b901ebef 100644 --- a/app/src/main/assets/packages/super_admin.js +++ b/app/src/main/assets/packages/super_admin.js @@ -2,34 +2,57 @@ METADATA { "name": "super_admin", - "display_name": { "zh": "超级管理员", "en": "Super Admin" }, - "description": { "zh": "超级管理员工具集,提供终端命令和Shell操作的高级功能。terminal工具运行在Ubuntu环境中(已正确挂载sdcard和storage),shell工具通过Shizuku/Root直接执行Android系统命令。适合需要进行底层系统管理和命令行操作的场景。", "en": "Super admin toolkit providing advanced terminal and shell capabilities. The terminal tool runs in an Ubuntu environment (with sdcard/storage mounted). The shell tool executes Android system commands directly via Shizuku/Root. Useful for low-level system administration and CLI operations." }, + "description": { + "zh": "超级管理员工具集,提供终端命令和Shell操作的高级功能。terminal工具运行在Ubuntu环境中(已正确挂载sdcard和storage),shell工具通过Shizuku/Root直接执行Android系统命令。适合需要进行底层系统管理和命令行操作的场景。", + "en": "Super admin toolkit providing advanced terminal and shell capabilities. The terminal tool runs in an Ubuntu environment (with sdcard/storage mounted). The shell tool executes Android system commands directly via Shizuku/Root. Useful for low-level system administration and CLI operations." + }, "enabledByDefault": true, "category": "System", "tools": [ { "name": "terminal", - "description": { "zh": "在Ubuntu环境中执行命令并收集输出结果,可访问Android存储空间。正常情况下复用当前对话的终端会话。建议显式传 timeoutMs;前台默认15秒,background=true 时不使用该默认超时。排队超时只取消本次调用;执行中超时先尝试中断,无法恢复则关闭故障会话,后续调用新建会话。会话重建后原工作目录和变量不再保留,命令不会自动重试。context_preserved=true 仅表示本次调用复用了已有会话且未超时,不保证跨故障保留上下文。避免使用 set -e 或 set -o errexit,以免改变会话退出行为。", "en": "Execute commands in Ubuntu with access to Android storage. Normally reuses the current chat's terminal session. Specify timeoutMs; foreground defaults to 15s, while background=true does not use this default. A queued timeout cancels only that call. A running timeout first attempts interruption; if recovery fails, the faulty session is closed and a later call creates a new one. A new session does not preserve the previous working directory or variables. Commands are not retried automatically. context_preserved=true only means this call reused an existing session without timing out; it does not guarantee context across failures. Avoid set -e or set -o errexit, which change shell exit behavior." }, + "description": { + "zh": "在持久 Ubuntu 会话执行命令。默认等待10秒,未结束返回 status=running 和 runId,命令继续运行;用 terminal_wait 查询、terminal_cancel 取消。yieldMs 是本次等待时间,不会中断命令;timeoutMs 是执行总上限,默认30分钟。复用会话保留目录和变量。输出为最近尾部快照,outputTruncated 表示截断;大日志请在命令中重定向到文件。completed 只表示回到提示符,不保证命令退出码为0,应检查输出。不要在复用会话直接 exit,需要时用 (命令; exit $?)。", + "en": "Run a command in a persistent Ubuntu shell. Waits 10s by default; status=running and runId mean it continues. Use terminal_wait to poll or terminal_cancel to cancel. yieldMs bounds only this wait; timeoutMs bounds execution (default 30 minutes). Preserves cwd and variables. Output is a recent tail snapshot with outputTruncated; redirect large logs to a file. completed means the shell prompt returned, not a verified zero exit code. Use an explicit subshell for exit." + }, "parameters": [ { "name": "command", - "description": { "zh": "要执行的 Ubuntu 命令;切换目录请在命令中使用 cd。本工具不接受 environment/path/timeout 参数,超时参数名为 timeoutMs。", "en": "Ubuntu command to execute; use cd inside the command to change directory. environment/path/timeout are unsupported; the timeout parameter is timeoutMs." }, + "description": { + "zh": "要执行的 Ubuntu 命令;切换目录请在命令中使用 cd。本工具不接受 environment/path/timeout 参数,超时参数名为 timeoutMs。", + "en": "Ubuntu command to execute; use cd inside the command to change directory. environment/path/timeout are unsupported; the timeout parameter is timeoutMs." + }, "type": "string", "required": true }, { "name": "background", - "description": { "zh": "是否在后台运行命令,\"true\" 表示后台执行并立即返回,适合启动服务器等长时间运行的任务(AI 不会收到该命令的输出结果),\"false\" 或未提供则前台执行并等待并返回命令结果", "en": "Run command in background. 'true' runs in background and returns immediately (good for long-running tasks like servers; AI will not receive output). 'false' or omitted runs in foreground and returns the command result." }, + "description": { + "zh": "true:独立后台会话,立即返回可查询的runId。", + "en": "true: use a separate background session and immediately return a queryable runId." + }, "type": "string", "required": false }, { "name": "timeoutMs", - "description": { "zh": "可选超时(毫秒,最低3000ms)。强烈建议显式传入;未传时前台默认15000ms,background=true时不使用默认超时。", "en": "Optional timeout (ms, minimum 3000ms). Strongly recommended to pass explicitly; if omitted, foreground defaults to 15000ms, and background=true does not use the default timeout." }, + "description": { + "zh": "执行总上限(毫秒,至少3000),默认1800000;与短等待yieldMs分开。", + "en": "Execution deadline in ms, minimum 3000, default 1800000; independent of yieldMs." + }, + "type": "string", + "required": false + }, + { + "name": "yieldMs", + "description": { + "zh": "本次等待毫秒数,0–30000,默认10000。", + "en": "Wait for this call in ms, 0–30000, default 10000." + }, "type": "string", "required": false } @@ -37,17 +60,44 @@ METADATA }, { "name": "terminal_wait", - "description": { "zh": "通过向同一终端会话排入检测命令,等待前序命令完成。等待超时会取消本次检测;若仍在排队,不会中断前序命令。若检测已执行但无法中断,故障会话可能被关闭,不保证保留会话或上下文。", "en": "Wait for preceding commands by queueing a probe in the same terminal session. A timeout cancels this probe. If it is still queued, preceding commands are not interrupted. If an already running probe cannot be interrupted, the faulty session may be closed; session and context preservation are not guaranteed." }, + "description": { + "zh": "查询 terminal 返回的 runId,短暂等待完成并返回最新输出尾部快照。仍运行就返回running,不会中断命令或排入检测命令。不传runId时查询指定sessionId或当前会话最近一次任务。进程重启或记录淘汰后ID失效,不要自动重跑命令。", + "en": "Poll a runId from terminal and return the latest tail snapshot. A running task stays alive; no probe command is queued. Without runId, polls the latest task in sessionId or the current chat session. IDs expire after app restart or completed-record eviction; do not automatically rerun commands." + }, "parameters": [ + { + "name": "runId", + "description": { + "zh": "terminal 返回的任务ID,建议传入。", + "en": "Task ID returned by terminal; recommended." + }, + "type": "string", + "required": false + }, { "name": "sessionId", - "description": { "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." }, + "description": { + "zh": "兼容:不传runId时查询此会话最近任务。", + "en": "Compatibility: latest task in this session when runId is omitted." + }, + "type": "string", + "required": false + }, + { + "name": "yieldMs", + "description": { + "zh": "本次等待0–30000毫秒,默认10000。", + "en": "Wait 0–30000 ms; default 10000." + }, "type": "string", "required": false }, { "name": "timeoutMs", - "description": { "zh": "可选超时(毫秒,最低3000ms)。未传时默认300000ms(5分钟)。", "en": "Optional timeout (ms, minimum 3000ms). Defaults to 300000ms (5 minutes) if omitted." }, + "description": { + "zh": "旧参数,作为等待时间使用,最多30000毫秒,不取消任务。", + "en": "Legacy wait alias, capped at 30000 ms; never cancels the task." + }, "type": "string", "required": false } @@ -55,11 +105,17 @@ METADATA }, { "name": "terminal_getscreen", - "description": { "zh": "获取目标终端会话可见屏幕内容(仅一屏,不包含历史滚动缓冲)。可通过 sessionId 指定会话;不传则使用当前对话的默认会话。", "en": "Get the visible screen content for the target terminal session (single screen only, no scrollback history). Pass sessionId to target a session; if omitted, uses the current chat's default session." }, + "description": { + "zh": "获取目标终端会话可见屏幕内容(仅一屏,不包含历史滚动缓冲)。可通过 sessionId 指定会话;不传则使用当前对话的默认会话。", + "en": "Get the visible screen content for the target terminal session (single screen only, no scrollback history). Pass sessionId to target a session; if omitted, uses the current chat's default session." + }, "parameters": [ { "name": "sessionId", - "description": { "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." }, + "description": { + "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", + "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." + }, "type": "string", "required": false } @@ -67,23 +123,35 @@ METADATA }, { "name": "terminal_input", - "description": { "zh": "向目标终端会话写入输入。可通过 sessionId 指定会话;不传则使用当前对话的默认会话。input 与 control 至少传一个。常见用法:先写 input,再写 control=enter 提交;control=ctrl 且 input=c 可发送 Ctrl+C。", "en": "Write input to the target terminal session. Pass sessionId to target a session; if omitted, uses the current chat's default session. Provide at least one of input or control. Typical usage: send input first, then control=enter to submit; use control=ctrl with input=c for Ctrl+C." }, + "description": { + "zh": "向目标终端会话写入输入。可通过 sessionId 指定会话;不传则使用当前对话的默认会话。input 与 control 至少传一个。常见用法:先写 input,再写 control=enter 提交;control=ctrl 且 input=c 可发送 Ctrl+C。", + "en": "Write input to the target terminal session. Pass sessionId to target a session; if omitted, uses the current chat's default session. Provide at least one of input or control. Typical usage: send input first, then control=enter to submit; use control=ctrl with input=c for Ctrl+C." + }, "parameters": [ { "name": "sessionId", - "description": { "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." }, + "description": { + "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", + "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." + }, "type": "string", "required": false }, { "name": "input", - "description": { "zh": "写入终端的文本", "en": "Text to write to terminal." }, + "description": { + "zh": "写入终端的文本", + "en": "Text to write to terminal." + }, "type": "string", "required": false }, { "name": "control", - "description": { "zh": "控制键,例如 enter / tab / esc / ctrl", "en": "Control key, e.g. enter / tab / esc / ctrl." }, + "description": { + "zh": "控制键,例如 enter / tab / esc / ctrl", + "en": "Control key, e.g. enter / tab / esc / ctrl." + }, "type": "string", "required": false } @@ -91,23 +159,44 @@ METADATA }, { "name": "shell", - "description": { "zh": "通过Shizuku/Root权限直接在Android系统中执行Shell命令。运行环境:直接访问Android系统,具有系统级权限,适用于需要操作Android系统底层的场景(如pm、am等系统命令)。", "en": "Execute shell commands directly on Android with Shizuku/Root. Environment: direct Android system access with system-level privileges, suitable for low-level commands such as pm/am." }, + "description": { + "zh": "通过Shizuku/Root权限直接在Android系统中执行Shell命令。运行环境:直接访问Android系统,具有系统级权限,适用于需要操作Android系统底层的场景(如pm、am等系统命令)。", + "en": "Execute shell commands directly on Android with Shizuku/Root. Environment: direct Android system access with system-level privileges, suitable for low-level commands such as pm/am." + }, "parameters": [ { "name": "command", - "description": { "zh": "要执行的 Android Shell 命令。本工具仅接受 command,不接受 timeoutMs;不要与 Ubuntu terminal 的参数混用。", "en": "Android Shell command to execute. This tool accepts only command, not timeoutMs; do not reuse Ubuntu terminal parameters." }, + "description": { + "zh": "要执行的 Android Shell 命令。本工具仅接受 command,不接受 timeoutMs;不要与 Ubuntu terminal 的参数混用。", + "en": "Android Shell command to execute. This tool accepts only command, not timeoutMs; do not reuse Ubuntu terminal parameters." + }, + "type": "string", + "required": true + } + ] + }, + { + "name": "terminal_cancel", + "description": { + "zh": "取消指定runId。排队任务只取消自己;执行中任务先中断,无法恢复时关闭故障会话。", + "en": "Cancel a runId. Queued tasks only cancel themselves; running tasks are interrupted, and an unrecoverable session is closed." + }, + "parameters": [ + { + "name": "runId", + "description": { + "zh": "terminal 返回的任务ID。", + "en": "Task ID from terminal." + }, "type": "string", "required": true } ] } ] -}*/ +} +*/ const superAdmin = (function () { - const MAX_INLINE_TERMINAL_OUTPUT_CHARS = 12000; - const DEFAULT_FOREGROUND_TIMEOUT_MS = 15000; - const DEFAULT_WAIT_TIMEOUT_MS = 300000; - const MIN_TIMEOUT_MS = 3000; const DEFAULT_TERMINAL_SESSION_NAME = "super_admin_default_session"; const BACKGROUND_TERMINAL_SESSION_PREFIX = "super_admin_background"; function getCurrentChatSessionSuffix() { @@ -134,163 +223,50 @@ const superAdmin = (function () { : BACKGROUND_TERMINAL_SESSION_PREFIX; return `${prefix}_${Date.now()}`; } - async function persistTerminalOutputIfTooLong(command, result) { - const outputStr = typeof result?.output === "string" - ? result.output - : String(result?.output ?? ""); - if (outputStr.length <= MAX_INLINE_TERMINAL_OUTPUT_CHARS) { - return null; + function waitMs(value, fallback = 10000) { + if (value === undefined) + return fallback; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 30000) { + throw new Error("yieldMs must be an integer between 0 and 30000"); } - await Tools.Files.mkdir(OPERIT_CLEAN_ON_EXIT_DIR, true); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const rand = Math.floor(Math.random() * 1000000); - const filePath = `${OPERIT_CLEAN_ON_EXIT_DIR}/terminal_output_${timestamp}_${rand}.log`; - await Tools.Files.write(filePath, outputStr, false); - return { - command, - output: "(saved_to_file)", - exitCode: result?.exitCode, - sessionId: result?.sessionId, - timedOut: result?.timedOut === true, - context_preserved: result?.timedOut !== true, - output_saved_to: filePath, - output_chars: outputStr.length, - operit_clean_on_exit_dir: OPERIT_CLEAN_ON_EXIT_DIR, - hint: "Output is large and saved to file. Use read_file_part or grep_code to inspect it.", - }; + return parsed; } - /** - * 在Ubuntu环境中执行终端命令并收集输出结果 - * 运行环境:完整的Ubuntu系统,已正确挂载sdcard和storage目录 - * 避免使用 set -e / set -o errexit,以免改变会话退出行为。 - * @param command - 要执行的命令 - * @param background - 是否后台运行("true" 为后台执行并立即返回,适合启动服务器等长时间运行任务,AI 不会收到该命令的输出结果) - * @param timeoutMs - 可选的超时时间(毫秒,最低 3000ms)。强烈建议显式传入;前台未传时默认 15000ms,后台模式不应用该默认值。 - */ async function terminal(params) { - try { - if (!params.command) { - throw new Error("命令不能为空"); - } - const command = params.command; - const background = params.background; - const timeoutMs = params.timeoutMs; - console.log(`执行终端命令: ${command}`); - const isBackground = background === "true"; - let timeout; - if (!isBackground) { - if (timeoutMs !== undefined) { - const parsedTimeout = parseInt(timeoutMs, 10); - if (!Number.isFinite(parsedTimeout) || parsedTimeout < MIN_TIMEOUT_MS) { - throw new Error(`timeoutMs必须是整数且不少于${MIN_TIMEOUT_MS}毫秒`); - } - timeout = parsedTimeout; - } - else { - timeout = DEFAULT_FOREGROUND_TIMEOUT_MS; - } - } - if (isBackground) { - const session = await Tools.System.terminal.create(getBackgroundTerminalSessionName()); - const sessionId = session.sessionId; - // 调用系统工具执行终端命令 - (async () => { - try { - await Tools.System.terminal.exec(sessionId, command); - } - catch (error) { - console.error(`[terminal/background] 错误: ${error.message}`); - console.error(error.stack); - } - })(); - return { - command: command, - background: true, - sessionId: sessionId, - started: true - }; - } - // 创建或获取一个默认会话 - const session = await Tools.System.terminal.create(getDefaultTerminalSessionName()); - const sessionId = session.sessionId; - // 调用系统工具执行终端命令 - const result = await Tools.System.terminal.exec(sessionId, command, timeout); - const timedOut = result.timedOut === true; - const persistedResult = await persistTerminalOutputIfTooLong(command, result); - if (persistedResult) { - persistedResult.timeoutMsUsed = timeout; - persistedResult.context_preserved = !session.isNewSession && !timedOut; - return persistedResult; - } - return { - command: command, - output: result.output, - exitCode: result.exitCode, - sessionId: result.sessionId, - timedOut: timedOut, - timeoutMsUsed: timeout, - context_preserved: !session.isNewSession && !timedOut - }; - } - catch (error) { - console.error(`[terminal] 错误: ${error.message}`); - console.error(error.stack); - throw error; + if (!params.command?.trim()) + throw new Error("command is required"); + const timeoutMs = params.timeoutMs === undefined ? 1800000 : Number(params.timeoutMs); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 3000) { + throw new Error("timeoutMs must be an integer of at least 3000"); } + const background = params.background === "true"; + const yieldMs = background ? 0 : waitMs(params.yieldMs); + const session = await Tools.System.terminal.create(background ? getBackgroundTerminalSessionName() : getDefaultTerminalSessionName()); + const result = await Tools.System.terminal.start(session.sessionId, params.command, { timeoutMs, yieldMs }); + return { ...result, command: params.command, background, timeoutMsUsed: timeoutMs, + context_preserved: !session.isNewSession && result.status !== "failed" && !result.timedOut }; } async function bash(params) { return terminal(params); } - /** - * 等待同一终端会话中的上一条命令执行完成 - * 原理:向同会话追加一个内部 marker 命令。由于会话按序执行,marker 开始执行即代表前序命令已完成。 - * @param sessionId - 可选会话ID;不传时使用当前对话的默认会话,无 chatId 时为 super_admin_default_session - * @param timeoutMs - 可选超时(毫秒,最低 3000ms);未传默认 300000ms - */ async function terminal_wait(params = {}) { - try { - if (Object.prototype.hasOwnProperty.call(params, "command")) { - throw new Error("terminal_wait only waits; command was not executed. Use super_admin:terminal to execute a command."); - } - const timeoutMs = params.timeoutMs; - let timeout = DEFAULT_WAIT_TIMEOUT_MS; - if (timeoutMs !== undefined) { - const parsedTimeout = parseInt(timeoutMs, 10); - if (!Number.isFinite(parsedTimeout) || parsedTimeout < MIN_TIMEOUT_MS) { - throw new Error(`timeoutMs必须是整数且不少于${MIN_TIMEOUT_MS}毫秒`); - } - timeout = parsedTimeout; - } - const session = params.sessionId - ? { sessionId: params.sessionId, isNewSession: false } - : await Tools.System.terminal.create(getDefaultTerminalSessionName()); - const sessionId = session.sessionId; - const marker = `__OPERIT_TERMINAL_WAIT_DONE_${Date.now()}_${Math.floor(Math.random() * 1000000)}__`; - const waitCommand = `printf '${marker}\\n'`; - const startedAt = Date.now(); - const result = await Tools.System.terminal.exec(sessionId, waitCommand, timeout); - const elapsedMs = Date.now() - startedAt; - const timedOut = result?.timedOut === true; - const outputStr = typeof result?.output === "string" - ? result.output - : String(result?.output ?? ""); - const markerSeen = outputStr.includes(marker); - return { - sessionId, - timedOut, - timeoutMsUsed: timeout, - elapsedMs, - waitCompleted: !timedOut && markerSeen, - markerSeen, - exitCode: result?.exitCode, - context_preserved: !session.isNewSession && !timedOut - }; + if (Object.prototype.hasOwnProperty.call(params, "command")) { + throw new Error("terminal_wait does not execute commands; use terminal."); } - catch (error) { - console.error(`[terminal_wait] 错误: ${error.message}`); - console.error(error.stack); - throw error; + const legacyWait = params.timeoutMs === undefined ? undefined : Number(params.timeoutMs); + if (legacyWait !== undefined && (!Number.isSafeInteger(legacyWait) || legacyWait < 0)) { + throw new Error("timeoutMs must be a non-negative integer"); } + const yieldMs = params.yieldMs !== undefined ? waitMs(params.yieldMs) + : legacyWait === undefined ? 10000 : Math.min(30000, legacyWait); + const sessionId = params.sessionId ?? (params.runId ? undefined + : (await Tools.System.terminal.create(getDefaultTerminalSessionName())).sessionId); + return Tools.System.terminal.poll({ runId: params.runId, sessionId, yieldMs }); + } + async function terminal_cancel(params) { + if (!params.runId) + throw new Error("runId is required"); + return Tools.System.terminal.cancel(params.runId); } /** * 通过Shizuku/Root权限在Android系统中执行Shell命令 @@ -378,6 +354,7 @@ const superAdmin = (function () { terminal, bash, terminal_wait, + terminal_cancel, terminal_getscreen, terminal_input, shell @@ -387,6 +364,7 @@ const superAdmin = (function () { exports.terminal = superAdmin.terminal; exports.bash = superAdmin.bash; exports.terminal_wait = superAdmin.terminal_wait; +exports.terminal_cancel = superAdmin.terminal_cancel; exports.terminal_getscreen = superAdmin.terminal_getscreen; exports.terminal_input = superAdmin.terminal_input; exports.shell = superAdmin.shell; diff --git a/app/src/main/java/com/ai/assistance/operit/core/tools/ToolRegistration.kt b/app/src/main/java/com/ai/assistance/operit/core/tools/ToolRegistration.kt index ce5de42b7d..8ceb0764c3 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/tools/ToolRegistration.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/tools/ToolRegistration.kt @@ -382,6 +382,23 @@ fun registerAllTools(handler: AIToolHandler, context: Context) { } ) + for (action in listOf("start", "poll", "cancel")) { + handler.registerTool( + name = "${action}_terminal_task", + descriptionGenerator = { tool -> + val label = when (action) { + "start" -> R.string.terminal_task_start_desc + "poll" -> R.string.terminal_task_poll_desc + else -> R.string.terminal_task_cancel_desc + } + s(label, tool.parameters.find { + it.name == if (action == "start") "command" else "run_id" + }?.value ?: "") + }, + executor = { tool -> ToolGetter.getTerminalCommandExecutor(context).terminalTask(tool, action) } + ) + } + // 终端命令执行工具 - 一次性收集输出 handler.registerTool( name = "create_terminal_session", diff --git a/app/src/main/java/com/ai/assistance/operit/core/tools/ToolResultDataClasses.kt b/app/src/main/java/com/ai/assistance/operit/core/tools/ToolResultDataClasses.kt index 0e3ad7275e..e10bbdc0f9 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/tools/ToolResultDataClasses.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/tools/ToolResultDataClasses.kt @@ -283,6 +283,21 @@ data class TerminalCommandResultData( } } +/** 终端命令流式事件数据 */ +@Serializable +data class TerminalTaskResultData( + val runId: String, + val sessionId: String, + val status: String, + val output: String, + val outputTruncated: Boolean, + val terminationReason: String? = null, + val outputMode: String, + val timedOut: Boolean, +) : ToolResultData() { + override fun toString(): String = "Terminal task $runId: $status\n$output" +} + /** 终端命令流式事件数据 */ @Serializable data class TerminalStreamEventData( diff --git a/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/StandardTerminalCommandExecutor.kt b/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/StandardTerminalCommandExecutor.kt index 3d2ddd08f4..04bc37bbbc 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/StandardTerminalCommandExecutor.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/tools/defaultTool/standard/StandardTerminalCommandExecutor.kt @@ -7,6 +7,7 @@ import com.ai.assistance.operit.core.tools.* import com.ai.assistance.operit.data.model.AITool import com.ai.assistance.operit.data.model.ToolResult import com.ai.assistance.operit.core.tools.system.Terminal +import com.ai.assistance.operit.core.tools.system.TerminalTaskRegistry import com.ai.assistance.operit.terminal.provider.type.HiddenExecResult import com.ai.assistance.operit.terminal.view.domain.ansi.TerminalChar import kotlinx.coroutines.* @@ -23,8 +24,46 @@ class StandardTerminalCommandExecutor(private val context: Context) { companion object { // 用于将会话名称映射到会话ID private val sessionNameToIdMap = ConcurrentHashMap() + private val taskRegistry = TerminalTaskRegistry(CoroutineScope(SupervisorJob() + Dispatchers.IO)) } + fun terminalTask(tool: AITool, action: String): ToolResult = runBlocking { + var startedId: String? = null + try { + fun param(name: String) = tool.parameters.find { it.name == name }?.value + val terminal = Terminal.getInstance(context) + val yieldMs = param("yield_ms")?.toLongOrNull() ?: 10_000L + require(yieldMs in 0..30_000L) { "yield_ms must be between 0 and 30000." } + val id = if (action == "start") { + val sessionId = requireNotNull(param("session_id")) { "session_id is required." } + val command = requireNotNull(param("command")) { "command is required." } + require(command.isNotBlank()) { "command must not be empty." } + val timeoutMs = param("timeout_ms")?.toLongOrNull() ?: 1_800_000L + require(timeoutMs >= 3_000L) { "timeout_ms must be at least 3000." } + taskRegistry.start(sessionId, timeoutMs) { commandId -> + terminal.executeCommandFlow(sessionId, command, commandId) + }.also { startedId = it } + } else { + param("run_id") ?: param("session_id")?.let(taskRegistry::latest) + ?: error("No terminal task found. Pass the runId returned by terminal.") + } + val result = if (action == "cancel") taskRegistry.cancel(id) else taskRegistry.poll(id, yieldMs) + val session = terminal.terminalState.value.sessions.find { it.id == result.sessionId } + val status = if (result.status == "running" && session != null && + synchronized(session.commandLifecycle) { session.commandQueue.any { it.id == result.runId } } + ) "queued" else result.status + ToolResult(tool.name, true, TerminalTaskResultData( + result.runId, result.sessionId, status, result.output, + result.outputTruncated, result.terminationReason, + outputMode = "tail_snapshot", timedOut = result.status == "timed_out" + )) + } catch (e: CancellationException) { + startedId?.let { withContext(NonCancellable) { taskRegistry.cancel(it) } } + throw e + } catch (e: Exception) { + ToolResult(tool.name, false, StringResultData(""), e.message) + } + } /** 创建或获取一个终端会话 */ fun createOrGetSession(tool: AITool): ToolResult { diff --git a/app/src/main/java/com/ai/assistance/operit/core/tools/javascript/JsTools.kt b/app/src/main/java/com/ai/assistance/operit/core/tools/javascript/JsTools.kt index a2b29757bb..d85105c62e 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/tools/javascript/JsTools.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/tools/javascript/JsTools.kt @@ -803,6 +803,13 @@ fun getJsToolsDefinition(): String { // 执行终端命令 - 一次性收集输出 terminal: { create: (sessionName) => toolCall("create_terminal_session", { session_name: sessionName }), + start: (sessionId, command, options = {}) => toolCall("start_terminal_task", { + session_id: sessionId, command, timeout_ms: options.timeoutMs, yield_ms: options.yieldMs + }), + poll: (options = {}) => toolCall("poll_terminal_task", { + run_id: options.runId, session_id: options.sessionId, yield_ms: options.yieldMs + }), + cancel: (runId) => toolCall("cancel_terminal_task", { run_id: runId }), exec: (sessionId, command, timeoutMs) => { const params = { session_id: sessionId, command }; if (timeoutMs !== undefined && timeoutMs !== null) { diff --git a/app/src/main/java/com/ai/assistance/operit/core/tools/system/Terminal.kt b/app/src/main/java/com/ai/assistance/operit/core/tools/system/Terminal.kt index 429a13f45f..9922a0aa61 100644 --- a/app/src/main/java/com/ai/assistance/operit/core/tools/system/Terminal.kt +++ b/app/src/main/java/com/ai/assistance/operit/core/tools/system/Terminal.kt @@ -32,6 +32,12 @@ import java.util.UUID */ @RequiresApi(Build.VERSION_CODES.O) class Terminal internal constructor(private val terminalManager: TerminalManager) { + init { + terminalManager.lifecycleLogger = { message, error -> + if (error == null) AppLogger.i("TerminalLifecycle", message) + else AppLogger.e("TerminalLifecycle", message, error) + } + } companion object { @Volatile diff --git a/app/src/main/java/com/ai/assistance/operit/core/tools/system/TerminalTaskRegistry.kt b/app/src/main/java/com/ai/assistance/operit/core/tools/system/TerminalTaskRegistry.kt new file mode 100644 index 0000000000..81db89b402 --- /dev/null +++ b/app/src/main/java/com/ai/assistance/operit/core/tools/system/TerminalTaskRegistry.kt @@ -0,0 +1,126 @@ +package com.ai.assistance.operit.core.tools.system + +import com.ai.assistance.operit.terminal.CommandExecutionEvent +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collect +import java.util.UUID + +/** Owns command collectors independently of any individual start/poll request. */ +internal class TerminalTaskRegistry( + private val scope: CoroutineScope, + private val capacity: Int = 32, + private val outputLimit: Int = 64_000, +) { + internal data class Snapshot( + val runId: String, + val sessionId: String, + val status: String, + val output: String, + val outputTruncated: Boolean, + val terminationReason: String?, + ) + + private class Run(val id: String, val sessionId: String) { + val done = CompletableDeferred() + lateinit var job: Job + var status = "running" + var output = "" + var truncated = false + var reason: String? = null + } + + private val runs = linkedMapOf() + + @Synchronized + fun start(sessionId: String, timeoutMs: Long, collect: (String) -> Flow): String { + while (runs.size >= capacity) { + val expired = runs.entries.firstOrNull { it.value.done.isCompleted } + ?: error("Too many active terminal tasks; wait for or cancel an existing task.") + runs.remove(expired.key) + } + val id = UUID.randomUUID().toString() + val run = Run(id, sessionId) + runs[id] = run + run.job = scope.launch(start = CoroutineStart.LAZY) { + try { + withTimeout(timeoutMs) { + collect(id).collect { event -> + synchronized(this@TerminalTaskRegistry) { + val text = if (event.isCompleted) { + event.outputChunk.ifEmpty { run.output } + } else if (run.output.isEmpty()) event.outputChunk + else run.output + "\n" + event.outputChunk + run.truncated = run.truncated || text.length > outputLimit + run.output = text.takeLast(outputLimit) + if (event.isCompleted) { + run.reason = event.terminationReason + run.status = when (event.terminationReason) { + null -> "completed" + "cancelled" -> "cancelled" + else -> "failed" + } + } + } + } + } + synchronized(this@TerminalTaskRegistry) { + if (run.status == "running") { + run.status = "failed" + run.reason = "missing_completion" + } + } + } catch (e: TimeoutCancellationException) { + finish(run, "timed_out", "execution_timeout") + } catch (e: CancellationException) { + finish(run, "cancelled", "cancelled") + } catch (e: Exception) { + finish(run, "failed", e.message ?: e.javaClass.simpleName) + } finally { + run.done.complete(Unit) + } + } + run.job.invokeOnCompletion { cause -> + synchronized(this) { + if (!run.done.isCompleted) { + if (run.status == "running" || run.status == "cancelling") { + run.status = if (cause is CancellationException) "cancelled" else "failed" + run.reason = cause?.message ?: "missing_completion" + } + run.done.complete(Unit) + } + } + } + run.job.start() + return id + } + + @Synchronized private fun finish(run: Run, status: String, reason: String) { + run.status = status + run.reason = reason + } + + @Synchronized private fun get(id: String): Run = + runs[id] ?: error("Terminal task not found or expired; do not rerun the command automatically.") + + @Synchronized fun latest(sessionId: String): String? = + runs.values.lastOrNull { it.sessionId == sessionId }?.id + + suspend fun poll(id: String, yieldMs: Long): Snapshot { + val run = get(id) + if (yieldMs > 0) withTimeoutOrNull(yieldMs.coerceAtMost(30_000L)) { run.done.await() } + return synchronized(this) { + Snapshot(run.id, run.sessionId, run.status, run.output.takeLast(12_000), + run.truncated || run.output.length > 12_000, run.reason) + } + } + + suspend fun cancel(id: String): Snapshot { + val run = get(id) + synchronized(this) { if (!run.done.isCompleted) run.status = "cancelling" } + // executeCommandFlow's finally cancels precisely this command, including queued work. + run.job.cancel() + withTimeoutOrNull(10_000L) { run.done.await() } + return poll(id, 0) + } +} diff --git a/app/src/main/java/com/ai/assistance/operit/ui/permissions/ToolPermissionPolicies.kt b/app/src/main/java/com/ai/assistance/operit/ui/permissions/ToolPermissionPolicies.kt index c0dfd80980..99963d528e 100644 --- a/app/src/main/java/com/ai/assistance/operit/ui/permissions/ToolPermissionPolicies.kt +++ b/app/src/main/java/com/ai/assistance/operit/ui/permissions/ToolPermissionPolicies.kt @@ -90,6 +90,8 @@ internal object WorkspaceToolPermissionPolicy { val normalizedName = tool.name.lowercase() if ( normalizedName == "create_terminal_session" || + normalizedName == "start_terminal_task" || + normalizedName == "cancel_terminal_task" || normalizedName == "execute_hidden_terminal_command" || normalizedName == "execute_in_terminal_session" || normalizedName == "execute_in_terminal_session_streaming" || diff --git a/app/src/main/res/values-en/strings.xml b/app/src/main/res/values-en/strings.xml index 2983319aad..f6d01a5972 100644 --- a/app/src/main/res/values-en/strings.xml +++ b/app/src/main/res/values-en/strings.xml @@ -9064,4 +9064,7 @@ Not enough free storage. Free some space and retry. Download failed. Please retry. The Ubuntu terminal environment must be downloaded first (about 64 MB) + Start terminal task: %1$s + Check terminal task: %1$s + Cancel terminal task: %1$s diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bec29183d3..f49f31d4cb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -9038,4 +9038,7 @@ 存储空间不足,请清理后重试 下载失败,请重试 需要先下载 Ubuntu 终端环境(约 64 MB) + 启动终端任务:%1$s + 查看终端任务:%1$s + 取消终端任务:%1$s diff --git a/app/src/test/java/com/ai/assistance/operit/core/tools/system/TerminalTaskRegistryTest.kt b/app/src/test/java/com/ai/assistance/operit/core/tools/system/TerminalTaskRegistryTest.kt new file mode 100644 index 0000000000..4fb17b9ea5 --- /dev/null +++ b/app/src/test/java/com/ai/assistance/operit/core/tools/system/TerminalTaskRegistryTest.kt @@ -0,0 +1,110 @@ +package com.ai.assistance.operit.core.tools.system + +import com.ai.assistance.operit.terminal.CommandExecutionEvent +import com.ai.assistance.operit.core.tools.TerminalTaskResultData +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.flow +import org.junit.Assert.* +import org.junit.Test + +class TerminalTaskRegistryTest { + private fun event(id: String, text: String, done: Boolean = false) = + CommandExecutionEvent(id, "session", text, done) + + @Test fun `bridge JSON preserves timeout and output semantics`() { + val result = TerminalTaskResultData("run", "session", "timed_out", "tail", false, + "execution_timeout", "tail_snapshot", true) + val json = Json.parseToJsonElement(result.toJson()).jsonObject + assertEquals("true", json.getValue("timedOut").jsonPrimitive.content) + assertEquals("tail_snapshot", json.getValue("outputMode").jsonPrimitive.content) + } + + @Test fun `yield and cancelled poll leave collector alive and final result is repeatable`() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + val registry = TerminalTaskRegistry(scope) + val started = CompletableDeferred() + val finish = CompletableDeferred() + var collectorCancelled = false + val id = registry.start("session", 10_000) { id -> flow { + try { + emit(event(id, "working")) + emit(event(id, "second line")) + started.complete(Unit) + finish.await() + emit(event(id, "finished", true)) + } finally { collectorCancelled = true } + } } + started.await() + assertEquals("running", registry.poll(id, 5).status) + assertEquals("working\nsecond line", registry.poll(id, 0).output) + val poller = launch { registry.poll(id, 30_000) } + yield() + poller.cancelAndJoin() + assertFalse(collectorCancelled) + finish.complete(Unit) + val result = registry.poll(id, 1_000) + assertEquals("completed", result.status) + assertEquals("finished", result.output) + assertEquals(result, registry.poll(id, 0)) + } finally { scope.cancel() } + } + + @Test fun `cancel targets only requested run and waits for collector cleanup`() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + val registry = TerminalTaskRegistry(scope) + val started = CompletableDeferred() + val cleaned = CompletableDeferred() + val a = registry.start("session", 10_000) { flow { + try { started.complete(Unit); awaitCancellation() } + finally { withContext(NonCancellable) { delay(10); cleaned.complete(Unit) } } + } } + val b = registry.start("session", 10_000) { flow { awaitCancellation() } } + started.await() + assertEquals("cancelled", registry.cancel(a).status) + assertTrue(cleaned.isCompleted) + assertEquals("running", registry.poll(b, 0).status) + } finally { scope.cancel() } + } + + @Test fun `execution deadline survives yield and reports timeout`() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + val registry = TerminalTaskRegistry(scope) + val id = registry.start("session", 30) { flow { awaitCancellation() } } + assertEquals("timed_out", registry.poll(id, 1_000).status) + } finally { scope.cancel() } + } + + @Test fun `bounded output and registry evict only finished tasks`() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + try { + val registry = TerminalTaskRegistry(scope, capacity = 1, outputLimit = 8) + val id = registry.start("session", 10_000) { id -> flow { + emit(event(id, "0123456789", true)) + } } + val result = registry.poll(id, 1_000) + assertEquals("23456789", result.output) + assertTrue(result.outputTruncated) + val active = registry.start("session", 10_000) { flow { awaitCancellation() } } + assertEquals(active, registry.latest("session")) + try { + registry.start("session", 10_000) { flow { awaitCancellation() } } + fail("Must not evict active tasks") + } catch (_: IllegalStateException) { } + assertEquals("running", registry.poll(active, 0).status) + } finally { scope.cancel() } + } + + @Test fun `cancelled owner scope does not leave a permanently running entry`() = runBlocking { + val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + scope.cancel() + val registry = TerminalTaskRegistry(scope) + val id = registry.start("session", 1000) { flow { awaitCancellation() } } + assertEquals("cancelled", registry.poll(id, 1000).status) + } +} diff --git a/app/src/test/java/com/ai/assistance/operit/terminal/CommandSessionCloseTest.kt b/app/src/test/java/com/ai/assistance/operit/terminal/CommandSessionCloseTest.kt index 768b3534c5..5adb2c3bba 100644 --- a/app/src/test/java/com/ai/assistance/operit/terminal/CommandSessionCloseTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/terminal/CommandSessionCloseTest.kt @@ -27,6 +27,7 @@ class CommandSessionCloseTest { running.outputPages.add("first page") session.currentExecutingCommand = running session.currentCommandOutput.append("last page") + session.rawBuffer.append(" tail without newline") session.commandQueue.addAll(listOf(QueuedCommand("b", "touch b"), QueuedCommand("c", "touch c"))) processor.finishClosedSession(session, "closed") @@ -36,6 +37,7 @@ class CommandSessionCloseTest { assertEquals(listOf("a", "b", "c"), events.map { it.commandId }) assertTrue(events.all { it.isCompleted && it.terminationReason == "session_closed" }) assertTrue(events.first().outputChunk.contains("first page\nlast page")) + assertTrue(events.first().outputChunk.contains("last page tail without newline\nclosed")) assertFalse(running.isExecuting) assertNull(session.currentExecutingCommand) assertTrue(session.commandQueue.isEmpty()) diff --git a/docs/doc-src/package-dev/system.md b/docs/doc-src/package-dev/system.md index 80613f40b5..3ff72cf103 100644 --- a/docs/doc-src/package-dev/system.md +++ b/docs/doc-src/package-dev/system.md @@ -262,10 +262,36 @@ sendBroadcast({ ### `terminal.exec(sessionId, command, timeoutMs?)` 在指定会话中执行命令,返回 `TerminalCommandResultData`。 -若发生超时,不会抛错;仍会正常返回结果,并带有 `timedOut = true`。 +这是原有阻塞接口,继续供依赖完整结果的包使用。超时会取消命令,无法恢复时会关闭会话;JS 桥可能将失败结果转为异常。 类型注释建议总是显式传入 `timeoutMs`。 +### 可查询的长任务 + +```ts +const session = await Tools.System.terminal.create("build"); +const task = await Tools.System.terminal.start(session.sessionId, "./gradlew build", { + yieldMs: 10000, // 本次最多等 10 秒;到时返回,不中断命令 + timeoutMs: 1800000 // 执行总上限(包含排队时间) +}); +const progress = await Tools.System.terminal.poll({ runId: task.runId, yieldMs: 10000 }); +// 需要停止时: +// await Tools.System.terminal.cancel(task.runId); +``` + +`start` 和 `poll` 默认等待 10 秒,允许 0–30000 毫秒。`start` 的执行上限默认 30 分钟。 +`super_admin:terminal`、`terminal_wait`、`terminal_cancel` 使用这组接口; +后台执行也会返回可查询的 `runId`。原有 `terminal.input` 仍按明确的 `sessionId` 输入。 + +结果包含 `runId`、`sessionId`、`status`、`output`、`outputMode`、`outputTruncated`、 +`timedOut` 和可选的 `terminationReason`。`queued`/`running` 表示尚未完成; +`completed` 表示回到提示符,并非验证了 Shell 的真实退出码;`cancelling` 表示正在收尾。 +查询返回最近 12000 字符的尾部快照(`outputMode=tail_snapshot`),不是增量, +不要将多次查询结果直接拼接。需要完整大日志时,在命令中重定向到文件。 + +每项任务最多缓存 64000 字符,最多保留 32 项任务;容量不足时只淘汰已结束记录, +不会自动杀掉运行中任务。应用退出后运行记录不持久保存,旧 ID 失效时不要自动重跑命令。 + ### `terminal.close(sessionId)` 关闭会话,返回 `TerminalSessionCloseResultData`。 diff --git a/examples/super_admin.js b/examples/super_admin.js index dde9f1a0af..44b901ebef 100644 --- a/examples/super_admin.js +++ b/examples/super_admin.js @@ -2,34 +2,57 @@ METADATA { "name": "super_admin", - "display_name": { "zh": "超级管理员", "en": "Super Admin" }, - "description": { "zh": "超级管理员工具集,提供终端命令和Shell操作的高级功能。terminal工具运行在Ubuntu环境中(已正确挂载sdcard和storage),shell工具通过Shizuku/Root直接执行Android系统命令。适合需要进行底层系统管理和命令行操作的场景。", "en": "Super admin toolkit providing advanced terminal and shell capabilities. The terminal tool runs in an Ubuntu environment (with sdcard/storage mounted). The shell tool executes Android system commands directly via Shizuku/Root. Useful for low-level system administration and CLI operations." }, + "description": { + "zh": "超级管理员工具集,提供终端命令和Shell操作的高级功能。terminal工具运行在Ubuntu环境中(已正确挂载sdcard和storage),shell工具通过Shizuku/Root直接执行Android系统命令。适合需要进行底层系统管理和命令行操作的场景。", + "en": "Super admin toolkit providing advanced terminal and shell capabilities. The terminal tool runs in an Ubuntu environment (with sdcard/storage mounted). The shell tool executes Android system commands directly via Shizuku/Root. Useful for low-level system administration and CLI operations." + }, "enabledByDefault": true, "category": "System", "tools": [ { "name": "terminal", - "description": { "zh": "在Ubuntu环境中执行命令并收集输出结果,可访问Android存储空间。正常情况下复用当前对话的终端会话。建议显式传 timeoutMs;前台默认15秒,background=true 时不使用该默认超时。排队超时只取消本次调用;执行中超时先尝试中断,无法恢复则关闭故障会话,后续调用新建会话。会话重建后原工作目录和变量不再保留,命令不会自动重试。context_preserved=true 仅表示本次调用复用了已有会话且未超时,不保证跨故障保留上下文。避免使用 set -e 或 set -o errexit,以免改变会话退出行为。", "en": "Execute commands in Ubuntu with access to Android storage. Normally reuses the current chat's terminal session. Specify timeoutMs; foreground defaults to 15s, while background=true does not use this default. A queued timeout cancels only that call. A running timeout first attempts interruption; if recovery fails, the faulty session is closed and a later call creates a new one. A new session does not preserve the previous working directory or variables. Commands are not retried automatically. context_preserved=true only means this call reused an existing session without timing out; it does not guarantee context across failures. Avoid set -e or set -o errexit, which change shell exit behavior." }, + "description": { + "zh": "在持久 Ubuntu 会话执行命令。默认等待10秒,未结束返回 status=running 和 runId,命令继续运行;用 terminal_wait 查询、terminal_cancel 取消。yieldMs 是本次等待时间,不会中断命令;timeoutMs 是执行总上限,默认30分钟。复用会话保留目录和变量。输出为最近尾部快照,outputTruncated 表示截断;大日志请在命令中重定向到文件。completed 只表示回到提示符,不保证命令退出码为0,应检查输出。不要在复用会话直接 exit,需要时用 (命令; exit $?)。", + "en": "Run a command in a persistent Ubuntu shell. Waits 10s by default; status=running and runId mean it continues. Use terminal_wait to poll or terminal_cancel to cancel. yieldMs bounds only this wait; timeoutMs bounds execution (default 30 minutes). Preserves cwd and variables. Output is a recent tail snapshot with outputTruncated; redirect large logs to a file. completed means the shell prompt returned, not a verified zero exit code. Use an explicit subshell for exit." + }, "parameters": [ { "name": "command", - "description": { "zh": "要执行的 Ubuntu 命令;切换目录请在命令中使用 cd。本工具不接受 environment/path/timeout 参数,超时参数名为 timeoutMs。", "en": "Ubuntu command to execute; use cd inside the command to change directory. environment/path/timeout are unsupported; the timeout parameter is timeoutMs." }, + "description": { + "zh": "要执行的 Ubuntu 命令;切换目录请在命令中使用 cd。本工具不接受 environment/path/timeout 参数,超时参数名为 timeoutMs。", + "en": "Ubuntu command to execute; use cd inside the command to change directory. environment/path/timeout are unsupported; the timeout parameter is timeoutMs." + }, "type": "string", "required": true }, { "name": "background", - "description": { "zh": "是否在后台运行命令,\"true\" 表示后台执行并立即返回,适合启动服务器等长时间运行的任务(AI 不会收到该命令的输出结果),\"false\" 或未提供则前台执行并等待并返回命令结果", "en": "Run command in background. 'true' runs in background and returns immediately (good for long-running tasks like servers; AI will not receive output). 'false' or omitted runs in foreground and returns the command result." }, + "description": { + "zh": "true:独立后台会话,立即返回可查询的runId。", + "en": "true: use a separate background session and immediately return a queryable runId." + }, "type": "string", "required": false }, { "name": "timeoutMs", - "description": { "zh": "可选超时(毫秒,最低3000ms)。强烈建议显式传入;未传时前台默认15000ms,background=true时不使用默认超时。", "en": "Optional timeout (ms, minimum 3000ms). Strongly recommended to pass explicitly; if omitted, foreground defaults to 15000ms, and background=true does not use the default timeout." }, + "description": { + "zh": "执行总上限(毫秒,至少3000),默认1800000;与短等待yieldMs分开。", + "en": "Execution deadline in ms, minimum 3000, default 1800000; independent of yieldMs." + }, + "type": "string", + "required": false + }, + { + "name": "yieldMs", + "description": { + "zh": "本次等待毫秒数,0–30000,默认10000。", + "en": "Wait for this call in ms, 0–30000, default 10000." + }, "type": "string", "required": false } @@ -37,17 +60,44 @@ METADATA }, { "name": "terminal_wait", - "description": { "zh": "通过向同一终端会话排入检测命令,等待前序命令完成。等待超时会取消本次检测;若仍在排队,不会中断前序命令。若检测已执行但无法中断,故障会话可能被关闭,不保证保留会话或上下文。", "en": "Wait for preceding commands by queueing a probe in the same terminal session. A timeout cancels this probe. If it is still queued, preceding commands are not interrupted. If an already running probe cannot be interrupted, the faulty session may be closed; session and context preservation are not guaranteed." }, + "description": { + "zh": "查询 terminal 返回的 runId,短暂等待完成并返回最新输出尾部快照。仍运行就返回running,不会中断命令或排入检测命令。不传runId时查询指定sessionId或当前会话最近一次任务。进程重启或记录淘汰后ID失效,不要自动重跑命令。", + "en": "Poll a runId from terminal and return the latest tail snapshot. A running task stays alive; no probe command is queued. Without runId, polls the latest task in sessionId or the current chat session. IDs expire after app restart or completed-record eviction; do not automatically rerun commands." + }, "parameters": [ + { + "name": "runId", + "description": { + "zh": "terminal 返回的任务ID,建议传入。", + "en": "Task ID returned by terminal; recommended." + }, + "type": "string", + "required": false + }, { "name": "sessionId", - "description": { "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." }, + "description": { + "zh": "兼容:不传runId时查询此会话最近任务。", + "en": "Compatibility: latest task in this session when runId is omitted." + }, + "type": "string", + "required": false + }, + { + "name": "yieldMs", + "description": { + "zh": "本次等待0–30000毫秒,默认10000。", + "en": "Wait 0–30000 ms; default 10000." + }, "type": "string", "required": false }, { "name": "timeoutMs", - "description": { "zh": "可选超时(毫秒,最低3000ms)。未传时默认300000ms(5分钟)。", "en": "Optional timeout (ms, minimum 3000ms). Defaults to 300000ms (5 minutes) if omitted." }, + "description": { + "zh": "旧参数,作为等待时间使用,最多30000毫秒,不取消任务。", + "en": "Legacy wait alias, capped at 30000 ms; never cancels the task." + }, "type": "string", "required": false } @@ -55,11 +105,17 @@ METADATA }, { "name": "terminal_getscreen", - "description": { "zh": "获取目标终端会话可见屏幕内容(仅一屏,不包含历史滚动缓冲)。可通过 sessionId 指定会话;不传则使用当前对话的默认会话。", "en": "Get the visible screen content for the target terminal session (single screen only, no scrollback history). Pass sessionId to target a session; if omitted, uses the current chat's default session." }, + "description": { + "zh": "获取目标终端会话可见屏幕内容(仅一屏,不包含历史滚动缓冲)。可通过 sessionId 指定会话;不传则使用当前对话的默认会话。", + "en": "Get the visible screen content for the target terminal session (single screen only, no scrollback history). Pass sessionId to target a session; if omitted, uses the current chat's default session." + }, "parameters": [ { "name": "sessionId", - "description": { "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." }, + "description": { + "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", + "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." + }, "type": "string", "required": false } @@ -67,23 +123,35 @@ METADATA }, { "name": "terminal_input", - "description": { "zh": "向目标终端会话写入输入。可通过 sessionId 指定会话;不传则使用当前对话的默认会话。input 与 control 至少传一个。常见用法:先写 input,再写 control=enter 提交;control=ctrl 且 input=c 可发送 Ctrl+C。", "en": "Write input to the target terminal session. Pass sessionId to target a session; if omitted, uses the current chat's default session. Provide at least one of input or control. Typical usage: send input first, then control=enter to submit; use control=ctrl with input=c for Ctrl+C." }, + "description": { + "zh": "向目标终端会话写入输入。可通过 sessionId 指定会话;不传则使用当前对话的默认会话。input 与 control 至少传一个。常见用法:先写 input,再写 control=enter 提交;control=ctrl 且 input=c 可发送 Ctrl+C。", + "en": "Write input to the target terminal session. Pass sessionId to target a session; if omitted, uses the current chat's default session. Provide at least one of input or control. Typical usage: send input first, then control=enter to submit; use control=ctrl with input=c for Ctrl+C." + }, "parameters": [ { "name": "sessionId", - "description": { "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." }, + "description": { + "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", + "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." + }, "type": "string", "required": false }, { "name": "input", - "description": { "zh": "写入终端的文本", "en": "Text to write to terminal." }, + "description": { + "zh": "写入终端的文本", + "en": "Text to write to terminal." + }, "type": "string", "required": false }, { "name": "control", - "description": { "zh": "控制键,例如 enter / tab / esc / ctrl", "en": "Control key, e.g. enter / tab / esc / ctrl." }, + "description": { + "zh": "控制键,例如 enter / tab / esc / ctrl", + "en": "Control key, e.g. enter / tab / esc / ctrl." + }, "type": "string", "required": false } @@ -91,23 +159,44 @@ METADATA }, { "name": "shell", - "description": { "zh": "通过Shizuku/Root权限直接在Android系统中执行Shell命令。运行环境:直接访问Android系统,具有系统级权限,适用于需要操作Android系统底层的场景(如pm、am等系统命令)。", "en": "Execute shell commands directly on Android with Shizuku/Root. Environment: direct Android system access with system-level privileges, suitable for low-level commands such as pm/am." }, + "description": { + "zh": "通过Shizuku/Root权限直接在Android系统中执行Shell命令。运行环境:直接访问Android系统,具有系统级权限,适用于需要操作Android系统底层的场景(如pm、am等系统命令)。", + "en": "Execute shell commands directly on Android with Shizuku/Root. Environment: direct Android system access with system-level privileges, suitable for low-level commands such as pm/am." + }, "parameters": [ { "name": "command", - "description": { "zh": "要执行的 Android Shell 命令。本工具仅接受 command,不接受 timeoutMs;不要与 Ubuntu terminal 的参数混用。", "en": "Android Shell command to execute. This tool accepts only command, not timeoutMs; do not reuse Ubuntu terminal parameters." }, + "description": { + "zh": "要执行的 Android Shell 命令。本工具仅接受 command,不接受 timeoutMs;不要与 Ubuntu terminal 的参数混用。", + "en": "Android Shell command to execute. This tool accepts only command, not timeoutMs; do not reuse Ubuntu terminal parameters." + }, + "type": "string", + "required": true + } + ] + }, + { + "name": "terminal_cancel", + "description": { + "zh": "取消指定runId。排队任务只取消自己;执行中任务先中断,无法恢复时关闭故障会话。", + "en": "Cancel a runId. Queued tasks only cancel themselves; running tasks are interrupted, and an unrecoverable session is closed." + }, + "parameters": [ + { + "name": "runId", + "description": { + "zh": "terminal 返回的任务ID。", + "en": "Task ID from terminal." + }, "type": "string", "required": true } ] } ] -}*/ +} +*/ const superAdmin = (function () { - const MAX_INLINE_TERMINAL_OUTPUT_CHARS = 12000; - const DEFAULT_FOREGROUND_TIMEOUT_MS = 15000; - const DEFAULT_WAIT_TIMEOUT_MS = 300000; - const MIN_TIMEOUT_MS = 3000; const DEFAULT_TERMINAL_SESSION_NAME = "super_admin_default_session"; const BACKGROUND_TERMINAL_SESSION_PREFIX = "super_admin_background"; function getCurrentChatSessionSuffix() { @@ -134,163 +223,50 @@ const superAdmin = (function () { : BACKGROUND_TERMINAL_SESSION_PREFIX; return `${prefix}_${Date.now()}`; } - async function persistTerminalOutputIfTooLong(command, result) { - const outputStr = typeof result?.output === "string" - ? result.output - : String(result?.output ?? ""); - if (outputStr.length <= MAX_INLINE_TERMINAL_OUTPUT_CHARS) { - return null; + function waitMs(value, fallback = 10000) { + if (value === undefined) + return fallback; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 30000) { + throw new Error("yieldMs must be an integer between 0 and 30000"); } - await Tools.Files.mkdir(OPERIT_CLEAN_ON_EXIT_DIR, true); - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const rand = Math.floor(Math.random() * 1000000); - const filePath = `${OPERIT_CLEAN_ON_EXIT_DIR}/terminal_output_${timestamp}_${rand}.log`; - await Tools.Files.write(filePath, outputStr, false); - return { - command, - output: "(saved_to_file)", - exitCode: result?.exitCode, - sessionId: result?.sessionId, - timedOut: result?.timedOut === true, - context_preserved: result?.timedOut !== true, - output_saved_to: filePath, - output_chars: outputStr.length, - operit_clean_on_exit_dir: OPERIT_CLEAN_ON_EXIT_DIR, - hint: "Output is large and saved to file. Use read_file_part or grep_code to inspect it.", - }; + return parsed; } - /** - * 在Ubuntu环境中执行终端命令并收集输出结果 - * 运行环境:完整的Ubuntu系统,已正确挂载sdcard和storage目录 - * 避免使用 set -e / set -o errexit,以免改变会话退出行为。 - * @param command - 要执行的命令 - * @param background - 是否后台运行("true" 为后台执行并立即返回,适合启动服务器等长时间运行任务,AI 不会收到该命令的输出结果) - * @param timeoutMs - 可选的超时时间(毫秒,最低 3000ms)。强烈建议显式传入;前台未传时默认 15000ms,后台模式不应用该默认值。 - */ async function terminal(params) { - try { - if (!params.command) { - throw new Error("命令不能为空"); - } - const command = params.command; - const background = params.background; - const timeoutMs = params.timeoutMs; - console.log(`执行终端命令: ${command}`); - const isBackground = background === "true"; - let timeout; - if (!isBackground) { - if (timeoutMs !== undefined) { - const parsedTimeout = parseInt(timeoutMs, 10); - if (!Number.isFinite(parsedTimeout) || parsedTimeout < MIN_TIMEOUT_MS) { - throw new Error(`timeoutMs必须是整数且不少于${MIN_TIMEOUT_MS}毫秒`); - } - timeout = parsedTimeout; - } - else { - timeout = DEFAULT_FOREGROUND_TIMEOUT_MS; - } - } - if (isBackground) { - const session = await Tools.System.terminal.create(getBackgroundTerminalSessionName()); - const sessionId = session.sessionId; - // 调用系统工具执行终端命令 - (async () => { - try { - await Tools.System.terminal.exec(sessionId, command); - } - catch (error) { - console.error(`[terminal/background] 错误: ${error.message}`); - console.error(error.stack); - } - })(); - return { - command: command, - background: true, - sessionId: sessionId, - started: true - }; - } - // 创建或获取一个默认会话 - const session = await Tools.System.terminal.create(getDefaultTerminalSessionName()); - const sessionId = session.sessionId; - // 调用系统工具执行终端命令 - const result = await Tools.System.terminal.exec(sessionId, command, timeout); - const timedOut = result.timedOut === true; - const persistedResult = await persistTerminalOutputIfTooLong(command, result); - if (persistedResult) { - persistedResult.timeoutMsUsed = timeout; - persistedResult.context_preserved = !session.isNewSession && !timedOut; - return persistedResult; - } - return { - command: command, - output: result.output, - exitCode: result.exitCode, - sessionId: result.sessionId, - timedOut: timedOut, - timeoutMsUsed: timeout, - context_preserved: !session.isNewSession && !timedOut - }; - } - catch (error) { - console.error(`[terminal] 错误: ${error.message}`); - console.error(error.stack); - throw error; + if (!params.command?.trim()) + throw new Error("command is required"); + const timeoutMs = params.timeoutMs === undefined ? 1800000 : Number(params.timeoutMs); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 3000) { + throw new Error("timeoutMs must be an integer of at least 3000"); } + const background = params.background === "true"; + const yieldMs = background ? 0 : waitMs(params.yieldMs); + const session = await Tools.System.terminal.create(background ? getBackgroundTerminalSessionName() : getDefaultTerminalSessionName()); + const result = await Tools.System.terminal.start(session.sessionId, params.command, { timeoutMs, yieldMs }); + return { ...result, command: params.command, background, timeoutMsUsed: timeoutMs, + context_preserved: !session.isNewSession && result.status !== "failed" && !result.timedOut }; } async function bash(params) { return terminal(params); } - /** - * 等待同一终端会话中的上一条命令执行完成 - * 原理:向同会话追加一个内部 marker 命令。由于会话按序执行,marker 开始执行即代表前序命令已完成。 - * @param sessionId - 可选会话ID;不传时使用当前对话的默认会话,无 chatId 时为 super_admin_default_session - * @param timeoutMs - 可选超时(毫秒,最低 3000ms);未传默认 300000ms - */ async function terminal_wait(params = {}) { - try { - if (Object.prototype.hasOwnProperty.call(params, "command")) { - throw new Error("terminal_wait only waits; command was not executed. Use super_admin:terminal to execute a command."); - } - const timeoutMs = params.timeoutMs; - let timeout = DEFAULT_WAIT_TIMEOUT_MS; - if (timeoutMs !== undefined) { - const parsedTimeout = parseInt(timeoutMs, 10); - if (!Number.isFinite(parsedTimeout) || parsedTimeout < MIN_TIMEOUT_MS) { - throw new Error(`timeoutMs必须是整数且不少于${MIN_TIMEOUT_MS}毫秒`); - } - timeout = parsedTimeout; - } - const session = params.sessionId - ? { sessionId: params.sessionId, isNewSession: false } - : await Tools.System.terminal.create(getDefaultTerminalSessionName()); - const sessionId = session.sessionId; - const marker = `__OPERIT_TERMINAL_WAIT_DONE_${Date.now()}_${Math.floor(Math.random() * 1000000)}__`; - const waitCommand = `printf '${marker}\\n'`; - const startedAt = Date.now(); - const result = await Tools.System.terminal.exec(sessionId, waitCommand, timeout); - const elapsedMs = Date.now() - startedAt; - const timedOut = result?.timedOut === true; - const outputStr = typeof result?.output === "string" - ? result.output - : String(result?.output ?? ""); - const markerSeen = outputStr.includes(marker); - return { - sessionId, - timedOut, - timeoutMsUsed: timeout, - elapsedMs, - waitCompleted: !timedOut && markerSeen, - markerSeen, - exitCode: result?.exitCode, - context_preserved: !session.isNewSession && !timedOut - }; + if (Object.prototype.hasOwnProperty.call(params, "command")) { + throw new Error("terminal_wait does not execute commands; use terminal."); } - catch (error) { - console.error(`[terminal_wait] 错误: ${error.message}`); - console.error(error.stack); - throw error; + const legacyWait = params.timeoutMs === undefined ? undefined : Number(params.timeoutMs); + if (legacyWait !== undefined && (!Number.isSafeInteger(legacyWait) || legacyWait < 0)) { + throw new Error("timeoutMs must be a non-negative integer"); } + const yieldMs = params.yieldMs !== undefined ? waitMs(params.yieldMs) + : legacyWait === undefined ? 10000 : Math.min(30000, legacyWait); + const sessionId = params.sessionId ?? (params.runId ? undefined + : (await Tools.System.terminal.create(getDefaultTerminalSessionName())).sessionId); + return Tools.System.terminal.poll({ runId: params.runId, sessionId, yieldMs }); + } + async function terminal_cancel(params) { + if (!params.runId) + throw new Error("runId is required"); + return Tools.System.terminal.cancel(params.runId); } /** * 通过Shizuku/Root权限在Android系统中执行Shell命令 @@ -378,6 +354,7 @@ const superAdmin = (function () { terminal, bash, terminal_wait, + terminal_cancel, terminal_getscreen, terminal_input, shell @@ -387,6 +364,7 @@ const superAdmin = (function () { exports.terminal = superAdmin.terminal; exports.bash = superAdmin.bash; exports.terminal_wait = superAdmin.terminal_wait; +exports.terminal_cancel = superAdmin.terminal_cancel; exports.terminal_getscreen = superAdmin.terminal_getscreen; exports.terminal_input = superAdmin.terminal_input; exports.shell = superAdmin.shell; diff --git a/examples/super_admin.ts b/examples/super_admin.ts index bbb72954bd..50c5be431d 100644 --- a/examples/super_admin.ts +++ b/examples/super_admin.ts @@ -2,34 +2,57 @@ METADATA { "name": "super_admin", - "display_name": { "zh": "超级管理员", "en": "Super Admin" }, - "description": { "zh": "超级管理员工具集,提供终端命令和Shell操作的高级功能。terminal工具运行在Ubuntu环境中(已正确挂载sdcard和storage),shell工具通过Shizuku/Root直接执行Android系统命令。适合需要进行底层系统管理和命令行操作的场景。", "en": "Super admin toolkit providing advanced terminal and shell capabilities. The terminal tool runs in an Ubuntu environment (with sdcard/storage mounted). The shell tool executes Android system commands directly via Shizuku/Root. Useful for low-level system administration and CLI operations." }, + "description": { + "zh": "超级管理员工具集,提供终端命令和Shell操作的高级功能。terminal工具运行在Ubuntu环境中(已正确挂载sdcard和storage),shell工具通过Shizuku/Root直接执行Android系统命令。适合需要进行底层系统管理和命令行操作的场景。", + "en": "Super admin toolkit providing advanced terminal and shell capabilities. The terminal tool runs in an Ubuntu environment (with sdcard/storage mounted). The shell tool executes Android system commands directly via Shizuku/Root. Useful for low-level system administration and CLI operations." + }, "enabledByDefault": true, "category": "System", "tools": [ { "name": "terminal", - "description": { "zh": "在Ubuntu环境中执行命令并收集输出结果,可访问Android存储空间。正常情况下复用当前对话的终端会话。建议显式传 timeoutMs;前台默认15秒,background=true 时不使用该默认超时。排队超时只取消本次调用;执行中超时先尝试中断,无法恢复则关闭故障会话,后续调用新建会话。会话重建后原工作目录和变量不再保留,命令不会自动重试。context_preserved=true 仅表示本次调用复用了已有会话且未超时,不保证跨故障保留上下文。避免使用 set -e 或 set -o errexit,以免改变会话退出行为。", "en": "Execute commands in Ubuntu with access to Android storage. Normally reuses the current chat's terminal session. Specify timeoutMs; foreground defaults to 15s, while background=true does not use this default. A queued timeout cancels only that call. A running timeout first attempts interruption; if recovery fails, the faulty session is closed and a later call creates a new one. A new session does not preserve the previous working directory or variables. Commands are not retried automatically. context_preserved=true only means this call reused an existing session without timing out; it does not guarantee context across failures. Avoid set -e or set -o errexit, which change shell exit behavior." }, + "description": { + "zh": "在持久 Ubuntu 会话执行命令。默认等待10秒,未结束返回 status=running 和 runId,命令继续运行;用 terminal_wait 查询、terminal_cancel 取消。yieldMs 是本次等待时间,不会中断命令;timeoutMs 是执行总上限,默认30分钟。复用会话保留目录和变量。输出为最近尾部快照,outputTruncated 表示截断;大日志请在命令中重定向到文件。completed 只表示回到提示符,不保证命令退出码为0,应检查输出。不要在复用会话直接 exit,需要时用 (命令; exit $?)。", + "en": "Run a command in a persistent Ubuntu shell. Waits 10s by default; status=running and runId mean it continues. Use terminal_wait to poll or terminal_cancel to cancel. yieldMs bounds only this wait; timeoutMs bounds execution (default 30 minutes). Preserves cwd and variables. Output is a recent tail snapshot with outputTruncated; redirect large logs to a file. completed means the shell prompt returned, not a verified zero exit code. Use an explicit subshell for exit." + }, "parameters": [ { "name": "command", - "description": { "zh": "要执行的 Ubuntu 命令;切换目录请在命令中使用 cd。本工具不接受 environment/path/timeout 参数,超时参数名为 timeoutMs。", "en": "Ubuntu command to execute; use cd inside the command to change directory. environment/path/timeout are unsupported; the timeout parameter is timeoutMs." }, + "description": { + "zh": "要执行的 Ubuntu 命令;切换目录请在命令中使用 cd。本工具不接受 environment/path/timeout 参数,超时参数名为 timeoutMs。", + "en": "Ubuntu command to execute; use cd inside the command to change directory. environment/path/timeout are unsupported; the timeout parameter is timeoutMs." + }, "type": "string", "required": true }, { "name": "background", - "description": { "zh": "是否在后台运行命令,\"true\" 表示后台执行并立即返回,适合启动服务器等长时间运行的任务(AI 不会收到该命令的输出结果),\"false\" 或未提供则前台执行并等待并返回命令结果", "en": "Run command in background. 'true' runs in background and returns immediately (good for long-running tasks like servers; AI will not receive output). 'false' or omitted runs in foreground and returns the command result." }, + "description": { + "zh": "true:独立后台会话,立即返回可查询的runId。", + "en": "true: use a separate background session and immediately return a queryable runId." + }, "type": "string", "required": false }, { "name": "timeoutMs", - "description": { "zh": "可选超时(毫秒,最低3000ms)。强烈建议显式传入;未传时前台默认15000ms,background=true时不使用默认超时。", "en": "Optional timeout (ms, minimum 3000ms). Strongly recommended to pass explicitly; if omitted, foreground defaults to 15000ms, and background=true does not use the default timeout." }, + "description": { + "zh": "执行总上限(毫秒,至少3000),默认1800000;与短等待yieldMs分开。", + "en": "Execution deadline in ms, minimum 3000, default 1800000; independent of yieldMs." + }, + "type": "string", + "required": false + }, + { + "name": "yieldMs", + "description": { + "zh": "本次等待毫秒数,0–30000,默认10000。", + "en": "Wait for this call in ms, 0–30000, default 10000." + }, "type": "string", "required": false } @@ -37,17 +60,44 @@ METADATA }, { "name": "terminal_wait", - "description": { "zh": "通过向同一终端会话排入检测命令,等待前序命令完成。等待超时会取消本次检测;若仍在排队,不会中断前序命令。若检测已执行但无法中断,故障会话可能被关闭,不保证保留会话或上下文。", "en": "Wait for preceding commands by queueing a probe in the same terminal session. A timeout cancels this probe. If it is still queued, preceding commands are not interrupted. If an already running probe cannot be interrupted, the faulty session may be closed; session and context preservation are not guaranteed." }, + "description": { + "zh": "查询 terminal 返回的 runId,短暂等待完成并返回最新输出尾部快照。仍运行就返回running,不会中断命令或排入检测命令。不传runId时查询指定sessionId或当前会话最近一次任务。进程重启或记录淘汰后ID失效,不要自动重跑命令。", + "en": "Poll a runId from terminal and return the latest tail snapshot. A running task stays alive; no probe command is queued. Without runId, polls the latest task in sessionId or the current chat session. IDs expire after app restart or completed-record eviction; do not automatically rerun commands." + }, "parameters": [ + { + "name": "runId", + "description": { + "zh": "terminal 返回的任务ID,建议传入。", + "en": "Task ID returned by terminal; recommended." + }, + "type": "string", + "required": false + }, { "name": "sessionId", - "description": { "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." }, + "description": { + "zh": "兼容:不传runId时查询此会话最近任务。", + "en": "Compatibility: latest task in this session when runId is omitted." + }, + "type": "string", + "required": false + }, + { + "name": "yieldMs", + "description": { + "zh": "本次等待0–30000毫秒,默认10000。", + "en": "Wait 0–30000 ms; default 10000." + }, "type": "string", "required": false }, { "name": "timeoutMs", - "description": { "zh": "可选超时(毫秒,最低3000ms)。未传时默认300000ms(5分钟)。", "en": "Optional timeout (ms, minimum 3000ms). Defaults to 300000ms (5 minutes) if omitted." }, + "description": { + "zh": "旧参数,作为等待时间使用,最多30000毫秒,不取消任务。", + "en": "Legacy wait alias, capped at 30000 ms; never cancels the task." + }, "type": "string", "required": false } @@ -55,11 +105,17 @@ METADATA }, { "name": "terminal_getscreen", - "description": { "zh": "获取目标终端会话可见屏幕内容(仅一屏,不包含历史滚动缓冲)。可通过 sessionId 指定会话;不传则使用当前对话的默认会话。", "en": "Get the visible screen content for the target terminal session (single screen only, no scrollback history). Pass sessionId to target a session; if omitted, uses the current chat's default session." }, + "description": { + "zh": "获取目标终端会话可见屏幕内容(仅一屏,不包含历史滚动缓冲)。可通过 sessionId 指定会话;不传则使用当前对话的默认会话。", + "en": "Get the visible screen content for the target terminal session (single screen only, no scrollback history). Pass sessionId to target a session; if omitted, uses the current chat's default session." + }, "parameters": [ { "name": "sessionId", - "description": { "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." }, + "description": { + "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", + "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." + }, "type": "string", "required": false } @@ -67,23 +123,35 @@ METADATA }, { "name": "terminal_input", - "description": { "zh": "向目标终端会话写入输入。可通过 sessionId 指定会话;不传则使用当前对话的默认会话。input 与 control 至少传一个。常见用法:先写 input,再写 control=enter 提交;control=ctrl 且 input=c 可发送 Ctrl+C。", "en": "Write input to the target terminal session. Pass sessionId to target a session; if omitted, uses the current chat's default session. Provide at least one of input or control. Typical usage: send input first, then control=enter to submit; use control=ctrl with input=c for Ctrl+C." }, + "description": { + "zh": "向目标终端会话写入输入。可通过 sessionId 指定会话;不传则使用当前对话的默认会话。input 与 control 至少传一个。常见用法:先写 input,再写 control=enter 提交;control=ctrl 且 input=c 可发送 Ctrl+C。", + "en": "Write input to the target terminal session. Pass sessionId to target a session; if omitted, uses the current chat's default session. Provide at least one of input or control. Typical usage: send input first, then control=enter to submit; use control=ctrl with input=c for Ctrl+C." + }, "parameters": [ { "name": "sessionId", - "description": { "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." }, + "description": { + "zh": "可选目标会话ID。不传则使用当前对话的默认会话;无 chatId 时为 super_admin_default_session。", + "en": "Optional target session ID. If omitted, uses the current chat's default session; without chatId it is super_admin_default_session." + }, "type": "string", "required": false }, { "name": "input", - "description": { "zh": "写入终端的文本", "en": "Text to write to terminal." }, + "description": { + "zh": "写入终端的文本", + "en": "Text to write to terminal." + }, "type": "string", "required": false }, { "name": "control", - "description": { "zh": "控制键,例如 enter / tab / esc / ctrl", "en": "Control key, e.g. enter / tab / esc / ctrl." }, + "description": { + "zh": "控制键,例如 enter / tab / esc / ctrl", + "en": "Control key, e.g. enter / tab / esc / ctrl." + }, "type": "string", "required": false } @@ -91,24 +159,45 @@ METADATA }, { "name": "shell", - "description": { "zh": "通过Shizuku/Root权限直接在Android系统中执行Shell命令。运行环境:直接访问Android系统,具有系统级权限,适用于需要操作Android系统底层的场景(如pm、am等系统命令)。", "en": "Execute shell commands directly on Android with Shizuku/Root. Environment: direct Android system access with system-level privileges, suitable for low-level commands such as pm/am." }, + "description": { + "zh": "通过Shizuku/Root权限直接在Android系统中执行Shell命令。运行环境:直接访问Android系统,具有系统级权限,适用于需要操作Android系统底层的场景(如pm、am等系统命令)。", + "en": "Execute shell commands directly on Android with Shizuku/Root. Environment: direct Android system access with system-level privileges, suitable for low-level commands such as pm/am." + }, "parameters": [ { "name": "command", - "description": { "zh": "要执行的 Android Shell 命令。本工具仅接受 command,不接受 timeoutMs;不要与 Ubuntu terminal 的参数混用。", "en": "Android Shell command to execute. This tool accepts only command, not timeoutMs; do not reuse Ubuntu terminal parameters." }, + "description": { + "zh": "要执行的 Android Shell 命令。本工具仅接受 command,不接受 timeoutMs;不要与 Ubuntu terminal 的参数混用。", + "en": "Android Shell command to execute. This tool accepts only command, not timeoutMs; do not reuse Ubuntu terminal parameters." + }, + "type": "string", + "required": true + } + ] + }, + { + "name": "terminal_cancel", + "description": { + "zh": "取消指定runId。排队任务只取消自己;执行中任务先中断,无法恢复时关闭故障会话。", + "en": "Cancel a runId. Queued tasks only cancel themselves; running tasks are interrupted, and an unrecoverable session is closed." + }, + "parameters": [ + { + "name": "runId", + "description": { + "zh": "terminal 返回的任务ID。", + "en": "Task ID from terminal." + }, "type": "string", "required": true } ] } ] -}*/ +} +*/ const superAdmin = (function () { - const MAX_INLINE_TERMINAL_OUTPUT_CHARS = 12_000; - const DEFAULT_FOREGROUND_TIMEOUT_MS = 15_000; - const DEFAULT_WAIT_TIMEOUT_MS = 300_000; - const MIN_TIMEOUT_MS = 3_000; const DEFAULT_TERMINAL_SESSION_NAME = "super_admin_default_session"; const BACKGROUND_TERMINAL_SESSION_PREFIX = "super_admin_background"; @@ -139,183 +228,52 @@ const superAdmin = (function () { return `${prefix}_${Date.now()}`; } - async function persistTerminalOutputIfTooLong(command: string, result: any): Promise { - const outputStr = typeof result?.output === "string" - ? result.output - : String(result?.output ?? ""); - - if (outputStr.length <= MAX_INLINE_TERMINAL_OUTPUT_CHARS) { - return null; + function waitMs(value: string | undefined, fallback = 10000): number { + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 30000) { + throw new Error("yieldMs must be an integer between 0 and 30000"); } - - await Tools.Files.mkdir(OPERIT_CLEAN_ON_EXIT_DIR, true); - - const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); - const rand = Math.floor(Math.random() * 1_000_000); - const filePath = `${OPERIT_CLEAN_ON_EXIT_DIR}/terminal_output_${timestamp}_${rand}.log`; - - await Tools.Files.write(filePath, outputStr, false); - - return { - command, - output: "(saved_to_file)", - exitCode: result?.exitCode, - sessionId: result?.sessionId, - timedOut: result?.timedOut === true, - context_preserved: result?.timedOut !== true, - output_saved_to: filePath, - output_chars: outputStr.length, - operit_clean_on_exit_dir: OPERIT_CLEAN_ON_EXIT_DIR, - hint: "Output is large and saved to file. Use read_file_part or grep_code to inspect it.", - }; + return parsed; } - /** - * 在Ubuntu环境中执行终端命令并收集输出结果 - * 运行环境:完整的Ubuntu系统,已正确挂载sdcard和storage目录 - * 避免使用 set -e / set -o errexit,以免改变会话退出行为。 - * @param command - 要执行的命令 - * @param background - 是否后台运行("true" 为后台执行并立即返回,适合启动服务器等长时间运行任务,AI 不会收到该命令的输出结果) - * @param timeoutMs - 可选的超时时间(毫秒,最低 3000ms)。强烈建议显式传入;前台未传时默认 15000ms,后台模式不应用该默认值。 - */ - async function terminal(params: { command: string, background?: string, timeoutMs?: string }): Promise { - try { - if (!params.command) { - throw new Error("命令不能为空"); - } - - const command = params.command; - const background = params.background; - const timeoutMs = params.timeoutMs; - - console.log(`执行终端命令: ${command}`); - - const isBackground = background === "true"; - let timeout: number | undefined; - if (!isBackground) { - if (timeoutMs !== undefined) { - const parsedTimeout = parseInt(timeoutMs, 10); - if (!Number.isFinite(parsedTimeout) || parsedTimeout < MIN_TIMEOUT_MS) { - throw new Error(`timeoutMs必须是整数且不少于${MIN_TIMEOUT_MS}毫秒`); - } - timeout = parsedTimeout; - } else { - timeout = DEFAULT_FOREGROUND_TIMEOUT_MS; - } - } - - if (isBackground) { - const session = await Tools.System.terminal.create(getBackgroundTerminalSessionName()); - const sessionId = session.sessionId; - - // 调用系统工具执行终端命令 - (async () => { - try { - await Tools.System.terminal.exec(sessionId, command); - } catch (error) { - console.error(`[terminal/background] 错误: ${error.message}`); - console.error(error.stack); - } - })(); - - return { - command: command, - background: true, - sessionId: sessionId, - started: true - }; - } - - // 创建或获取一个默认会话 - const session = await Tools.System.terminal.create(getDefaultTerminalSessionName()); - const sessionId = session.sessionId; - - // 调用系统工具执行终端命令 - const result = await Tools.System.terminal.exec(sessionId, command, timeout); - const timedOut = result.timedOut === true; - - const persistedResult = await persistTerminalOutputIfTooLong(command, result); - if (persistedResult) { - persistedResult.timeoutMsUsed = timeout; - persistedResult.context_preserved = !session.isNewSession && !timedOut; - return persistedResult; - } - - return { - command: command, - output: result.output, - exitCode: result.exitCode, - sessionId: result.sessionId, - timedOut: timedOut, - timeoutMsUsed: timeout, - context_preserved: !session.isNewSession && !timedOut - }; - } catch (error) { - console.error(`[terminal] 错误: ${error.message}`); - console.error(error.stack); - - throw error; + async function terminal(params: { command: string, background?: string, timeoutMs?: string, yieldMs?: string }): Promise { + if (!params.command?.trim()) throw new Error("command is required"); + const timeoutMs = params.timeoutMs === undefined ? 1800000 : Number(params.timeoutMs); + if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 3000) { + throw new Error("timeoutMs must be an integer of at least 3000"); } + const background = params.background === "true"; + const yieldMs = background ? 0 : waitMs(params.yieldMs); + const session = await Tools.System.terminal.create( + background ? getBackgroundTerminalSessionName() : getDefaultTerminalSessionName()); + const result = await Tools.System.terminal.start(session.sessionId, params.command, { timeoutMs, yieldMs }); + return { ...result, command: params.command, background, timeoutMsUsed: timeoutMs, + context_preserved: !session.isNewSession && result.status !== "failed" && !result.timedOut }; } - async function bash(params: { command: string, background?: string, timeoutMs?: string }): Promise { + async function bash(params: { command: string, background?: string, timeoutMs?: string, yieldMs?: string }): Promise { return terminal(params); } - /** - * 等待同一终端会话中的上一条命令执行完成 - * 原理:向同会话追加一个内部 marker 命令。由于会话按序执行,marker 开始执行即代表前序命令已完成。 - * @param sessionId - 可选会话ID;不传时使用当前对话的默认会话,无 chatId 时为 super_admin_default_session - * @param timeoutMs - 可选超时(毫秒,最低 3000ms);未传默认 300000ms - */ - async function terminal_wait(params: { sessionId?: string, timeoutMs?: string } = {}): Promise { - try { - if (Object.prototype.hasOwnProperty.call(params, "command")) { - throw new Error("terminal_wait only waits; command was not executed. Use super_admin:terminal to execute a command."); - } - const timeoutMs = params.timeoutMs; - let timeout = DEFAULT_WAIT_TIMEOUT_MS; - if (timeoutMs !== undefined) { - const parsedTimeout = parseInt(timeoutMs, 10); - if (!Number.isFinite(parsedTimeout) || parsedTimeout < MIN_TIMEOUT_MS) { - throw new Error(`timeoutMs必须是整数且不少于${MIN_TIMEOUT_MS}毫秒`); - } - timeout = parsedTimeout; - } - - const session = - params.sessionId - ? { sessionId: params.sessionId, isNewSession: false } - : await Tools.System.terminal.create(getDefaultTerminalSessionName()); - const sessionId = session.sessionId; - - const marker = `__OPERIT_TERMINAL_WAIT_DONE_${Date.now()}_${Math.floor(Math.random() * 1_000_000)}__`; - const waitCommand = `printf '${marker}\\n'`; - const startedAt = Date.now(); - const result = await Tools.System.terminal.exec(sessionId, waitCommand, timeout); - const elapsedMs = Date.now() - startedAt; - const timedOut = result?.timedOut === true; - - const outputStr = typeof result?.output === "string" - ? result.output - : String(result?.output ?? ""); - const markerSeen = outputStr.includes(marker); - - return { - sessionId, - timedOut, - timeoutMsUsed: timeout, - elapsedMs, - waitCompleted: !timedOut && markerSeen, - markerSeen, - exitCode: result?.exitCode, - context_preserved: !session.isNewSession && !timedOut - }; - } catch (error) { - console.error(`[terminal_wait] 错误: ${error.message}`); - console.error(error.stack); - throw error; + async function terminal_wait(params: { runId?: string, sessionId?: string, yieldMs?: string, timeoutMs?: string } = {}): Promise { + if (Object.prototype.hasOwnProperty.call(params, "command")) { + throw new Error("terminal_wait does not execute commands; use terminal."); + } + const legacyWait = params.timeoutMs === undefined ? undefined : Number(params.timeoutMs); + if (legacyWait !== undefined && (!Number.isSafeInteger(legacyWait) || legacyWait < 0)) { + throw new Error("timeoutMs must be a non-negative integer"); } + const yieldMs = params.yieldMs !== undefined ? waitMs(params.yieldMs) + : legacyWait === undefined ? 10000 : Math.min(30000, legacyWait); + const sessionId = params.sessionId ?? (params.runId ? undefined + : (await Tools.System.terminal.create(getDefaultTerminalSessionName())).sessionId); + return Tools.System.terminal.poll({ runId: params.runId, sessionId, yieldMs }); + } + + async function terminal_cancel(params: { runId: string }): Promise { + if (!params.runId) throw new Error("runId is required"); + return Tools.System.terminal.cancel(params.runId); } /** @@ -412,6 +370,7 @@ const superAdmin = (function () { terminal, bash, terminal_wait, + terminal_cancel, terminal_getscreen, terminal_input, shell @@ -422,6 +381,7 @@ const superAdmin = (function () { exports.terminal = superAdmin.terminal; exports.bash = superAdmin.bash; exports.terminal_wait = superAdmin.terminal_wait; +exports.terminal_cancel = superAdmin.terminal_cancel; exports.terminal_getscreen = superAdmin.terminal_getscreen; exports.terminal_input = superAdmin.terminal_input; exports.shell = superAdmin.shell; diff --git a/examples/types/results.d.ts b/examples/types/results.d.ts index 6608ef3b34..43fe617755 100644 --- a/examples/types/results.d.ts +++ b/examples/types/results.d.ts @@ -616,6 +616,18 @@ export interface IntentResultData { /** * Terminal command execution result data */ +export interface TerminalTaskResultData { + runId: string; + sessionId: string; + status: 'queued' | 'running' | 'completed' | 'cancelled' | 'cancelling' | 'failed' | 'timed_out'; + /** Latest tail snapshot, not a new output chunk. */ + output: string; + outputMode: 'tail_snapshot'; + outputTruncated: boolean; + terminationReason?: string; + timedOut: boolean; +} + export interface TerminalCommandResultData { /** The command that was executed */ command: string; diff --git a/examples/types/system.d.ts b/examples/types/system.d.ts index d7576f4bcd..fa50c068cd 100644 --- a/examples/types/system.d.ts +++ b/examples/types/system.d.ts @@ -267,6 +267,15 @@ export namespace System { * Terminal operations. */ namespace terminal { + /** Start a managed command. yieldMs only limits this wait; timeoutMs limits execution. */ + function start(sessionId: string, command: string, options?: { + yieldMs?: number; timeoutMs?: number; + }): Promise; + /** Return a bounded tail snapshot; polling never cancels the command. */ + function poll(options: { + runId?: string; sessionId?: string; yieldMs?: number; + }): Promise; + function cancel(runId: string): Promise; /** * Create or get a terminal session. * @param sessionName The name for the session. diff --git a/examples/types/tool-types.d.ts b/examples/types/tool-types.d.ts index 2e6c9f0cba..a83b3e24bd 100644 --- a/examples/types/tool-types.d.ts +++ b/examples/types/tool-types.d.ts @@ -5,6 +5,7 @@ */ import { + TerminalTaskResultData, DirectoryListingData, FileContentData, BinaryFileContentData, FileOperationData, FileExistsData, FindFilesResultData, FileInfoData, FileConversionResultData, FileFormatConversionsResultData, HttpResponseData, VisitWebResultData, @@ -171,6 +172,9 @@ export interface ToolResultMap { 'send_broadcast': IntentResultData; // Terminal operations + 'start_terminal_task': TerminalTaskResultData; + 'poll_terminal_task': TerminalTaskResultData; + 'cancel_terminal_task': TerminalTaskResultData; 'execute_terminal': TerminalCommandResultData; 'execute_in_terminal_session_streaming': TerminalCommandResultData; 'execute_hidden_terminal_command': HiddenTerminalCommandResultData; diff --git a/terminal b/terminal index 995b7d58ed..0c238c9bc8 160000 --- a/terminal +++ b/terminal @@ -1 +1 @@ -Subproject commit 995b7d58ed73198e35a42a44d866eaf6ae54d1a5 +Subproject commit 0c238c9bc8bbf581688af721da75be8bde2ce3c4 From 82d80852cc645aac87eab1307eca47698d164732 Mon Sep 17 00:00:00 2001 From: WaterRain <109326062+CATMIAOZHI@users.noreply.github.com> Date: Thu, 8 Oct 2026 08:14:57 +0800 Subject: [PATCH 10/10] test(memory): verify actionable edit recovery diagnostics --- .../chat/library/MemoryLearningBatchTest.kt | 21 +++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/app/src/test/java/com/ai/assistance/operit/api/chat/library/MemoryLearningBatchTest.kt b/app/src/test/java/com/ai/assistance/operit/api/chat/library/MemoryLearningBatchTest.kt index 7fe62fca3b..2c518b9ec8 100644 --- a/app/src/test/java/com/ai/assistance/operit/api/chat/library/MemoryLearningBatchTest.kt +++ b/app/src/test/java/com/ai/assistance/operit/api/chat/library/MemoryLearningBatchTest.kt @@ -386,7 +386,11 @@ class MemoryLearningBatchTest { "content" to "y".repeat(50))) fail("an add past the capacity must be rejected") } catch (e: IllegalArgumentException) { - assertTrue(e.message.orEmpty().contains("remove or replace existing text in this batch before adding")) + val message = e.message.orEmpty() + for (detail in listOf("memory.md capacity exceeded", "current_chars=", "proposed_chars=", + "max_chars=", "over_by=", "memory_read", "shorten or remove", "before retrying")) { + assertTrue("Missing recovery detail: $detail", message.contains(detail)) + } } // A rejected change must not be staged, or the reviewer would believe it landed. assertTrue(staged.isEmpty()) @@ -413,11 +417,16 @@ class MemoryLearningBatchTest { } catch (e: IllegalArgumentException) { e.message.orEmpty() } - // Ambiguous and missing old_text are refused with the same wording the direct writer uses. - assertEquals("old_text must match exactly once", - change("operation" to "remove","old_text" to "Kotlin")) - assertEquals("old_text must match exactly once", - change("operation" to "replace","old_text" to "missing","content" to "x")) + // Explain both why the edit was refused and how to obtain a unique, current span. + val ambiguous = change("operation" to "remove","old_text" to "Kotlin") + val missing = change("operation" to "replace","old_text" to "missing","content" to "x") + for (message in listOf(ambiguous, missing)) { + assertTrue(message.contains("old_text must match exactly once")) + assertTrue(message.contains("memory_read")) + assertTrue(message.contains("unique span")) + } + assertTrue(ambiguous.contains("found more than 1 matches")) + assertTrue(missing.contains("found 0 matches")) assertEquals("old_text and content are required", change("operation" to "remove","old_text" to " ")) assertEquals("content is required",