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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
311 changes: 146 additions & 165 deletions app/src/main/assets/packages/super_admin.js

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -2172,7 +2178,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(
Expand Down Expand Up @@ -2316,7 +2324,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,
Expand Down Expand Up @@ -2605,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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Int>()
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, String>): 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) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -92,15 +95,15 @@ 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 {
val repo = MemoryNotesRepository(context,profileId)
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)
}
Expand Down Expand Up @@ -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"))
Expand All @@ -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.
Expand All @@ -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")
}
}
}
Expand All @@ -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"
Expand Down Expand Up @@ -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."
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>())
private val consecutiveFailures = HashMap<String,Int>()
val retryGuard = LearningRetryGuard()
fun addFailure(detail: String) { failures.add(detail) }
fun failureList(): List<String> = 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()
Expand Down Expand Up @@ -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) {
Expand All @@ -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())
Expand All @@ -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() }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1508,15 +1508,15 @@ class ClaudeProvider(
)
}

val builtRequestBody = createRequestBody(
val builtRequestBody = withContext(Dispatchers.IO) { createRequestBody(
context,
chatHistory,
modelParameters,
enableThinking,
stream,
availableTools,
preserveThinkInHistory
)
) }
attemptedThinkingFormat = builtRequestBody.thinkingFormat
onTokensUpdated(
tokenCacheManager.totalInputTokenCount,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading
Loading