Skip to content

Commit be44eef

Browse files
committed
feat(agent): 增强代理工具调用和步骤限制处理
- 调整模型思考日志记录逻辑,只在中间步骤记录思考内容和即将调用工具 - 在达到最大步骤限制时,增加让模型总结当前情况并通知用户的逻辑 - 引入 wrap-up 总结消息,处理步骤耗尽后的回复生成和发送 - 将 maxSteps 参数从 6 增加到 60,提升代理允许的最大调用步数 - 新增 OneBot 通道支持,实现基础连接和消息输出功能 - 新增 GithubTool,支持通过 GitHub Contents API 浏览仓库目录与读取文件 - 在内置工具集合中注册 GithubTool - 添加对 overflow-core 库依赖支持
1 parent e29ba9c commit be44eef

6 files changed

Lines changed: 239 additions & 4 deletions

File tree

build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ dependencies {
1919
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3")
2020
implementation("org.slf4j:slf4j-simple:2.0.13")
2121

22+
implementation("top.mrxiaom.mirai:overflow-core:1.1.0")
23+
2224
testImplementation(kotlin("test"))
2325
}
2426

src/main/kotlin/org/example/vicky/agent/Agent.kt

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,6 @@ abstract class Agent(
112112
history += assistant
113113

114114
val calls = assistant.toolCalls.orEmpty()
115-
assistant.content?.takeIf { it.isNotBlank() }?.let { logThink(it) }
116115
if (calls.isEmpty()) {
117116
log("step ${step + 1}: no tool calls, finishing")
118117
if (config.mode.emitAgentText) {
@@ -127,9 +126,13 @@ abstract class Agent(
127126
return
128127
}
129128

129+
// 中间过程才算 think:模型的思考文本 + 即将调用的工具 (最后一轮的 content 是最终回答,不算)。
130+
assistant.content?.takeIf { it.isNotBlank() }?.let { logThink(it) }
131+
130132
for (call in calls) {
131133
if (call !is ToolCall.Function) continue
132134
val toolName = call.function.name
135+
logThink("Use Tool: $toolName")
133136
log("step ${step + 1}: invoking tool '$toolName'")
134137
val result = invokeTool(msg, toolName, call.function.argumentsAsJson())
135138
history += ChatMessage(
@@ -143,8 +146,26 @@ abstract class Agent(
143146
}
144147
}
145148
}
146-
// 达到 maxSteps 仍未给出最终回复,静默结束。
147-
log("reached maxSteps (${config.maxSteps}) without a final reply")
149+
// 步数耗尽:不再给工具,让模型基于已有信息整理现状并向用户汇报。
150+
log("reached maxSteps (${config.maxSteps}); requesting a wrap-up summary")
151+
history += ChatMessage(
152+
role = ChatRole.User,
153+
content = "System notice: the step budget for this turn is exhausted; no more tools can be " +
154+
"called. Based on the information gathered so far, summarize the current situation and " +
155+
"report your conclusion to the user, and explicitly note that some actions may be " +
156+
"incomplete because the step limit was reached. Reply in the user's language.",
157+
)
158+
val wrapUp = openAi.chatCompletion(
159+
ChatCompletionRequest(
160+
model = config.model,
161+
messages = history.toList(),
162+
temperature = config.temperature,
163+
)
164+
).choices.first().message
165+
history += wrapUp
166+
wrapUp.content?.takeIf { it.isNotBlank() }?.let {
167+
emit(OutboundMessage.AgentReply(msg.conversationId, msg.userId, it))
168+
}
148169
if (clearContextAfter) store.clear(msg.conversationId)
149170
}
150171

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package org.example.vicky.channel
2+
3+
import net.mamoe.mirai.Bot
4+
import org.example.vicky.agent.Agent
5+
import org.example.vicky.agent.AgentConfig
6+
import org.example.vicky.io.MessageSink
7+
import org.example.vicky.io.OutboundMessage
8+
import org.example.vicky.tool.ToolAuthorizer
9+
import top.mrxiaom.overflow.BotBuilder
10+
11+
//TODO 未完成
12+
13+
class OneBot(var agentConfig: AgentConfig, var url: String, var token: String) {
14+
var usePrintLog = false
15+
16+
val adminList = mutableSetOf<String>()
17+
val adminToolList = mutableSetOf<String>()
18+
19+
var bot: Bot? = null
20+
21+
val consoleAgent = ConsoleAgent(agentConfig, usePrintLog, adminList, adminToolList)
22+
23+
suspend fun connect(): Boolean {
24+
bot = BotBuilder.positive(url).token(token).connect()
25+
return bot != null
26+
}
27+
28+
class ConsoleAgent(config: AgentConfig, printLog: Boolean, adminList: Set<String>, adminToolList: Set<String>) : Agent(config) {
29+
override val sink = MessageSink { out ->
30+
if (!printLog) return@MessageSink
31+
when (out) {
32+
is OutboundMessage.AgentReply -> println("[Agent]" + out.content)
33+
is OutboundMessage.ToolReply -> println("[Tool:${out.toolName}]" + out.content)
34+
is OutboundMessage.Debug -> println("[Debug]" + out.content)
35+
is OutboundMessage.Think -> println("[Think]" + out.content)
36+
}
37+
}
38+
39+
override val authorizer = ToolAuthorizer { userId, toolName ->
40+
if (adminToolList.contains(toolName)) adminList.contains(userId) else false
41+
}
42+
}
43+
44+
}

src/main/kotlin/org/example/vicky/examples/ConsoleMain.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ fun main() = runBlocking {
134134
apiKey = apiKey,
135135
baseUrl = baseUrl,
136136
mode = AgentMode.VERBOSE,
137-
maxSteps = 6,
137+
maxSteps = 60,
138138
think = true
139139
)
140140
)

src/main/kotlin/org/example/vicky/tool/builtin/BuiltinTools.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,5 +30,6 @@ class ClearContextTool : Tool() {
3030
object BuiltinTools {
3131
fun all(): List<Tool> = listOf(
3232
ClearContextTool(),
33+
GithubTool(),
3334
)
3435
}
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
package org.example.vicky.tool.builtin
2+
3+
import kotlinx.coroutines.Dispatchers
4+
import kotlinx.coroutines.withContext
5+
import kotlinx.serialization.json.Json
6+
import kotlinx.serialization.json.JsonArray
7+
import kotlinx.serialization.json.JsonObject
8+
import kotlinx.serialization.json.buildJsonArray
9+
import kotlinx.serialization.json.buildJsonObject
10+
import kotlinx.serialization.json.int
11+
import kotlinx.serialization.json.jsonPrimitive
12+
import kotlinx.serialization.json.put
13+
import kotlinx.serialization.json.putJsonObject
14+
import org.example.vicky.tool.Tool
15+
import org.example.vicky.tool.ToolResult
16+
import java.net.URI
17+
import java.net.URLEncoder
18+
import java.net.http.HttpClient
19+
import java.net.http.HttpRequest
20+
import java.net.http.HttpResponse
21+
import java.util.Base64
22+
23+
/**
24+
* 访问 GitHub 仓库:列目录 / 读文件。基于 GitHub Contents API,单端点按 path 自动判断。
25+
*
26+
* - path 为目录 (或留空=根目录) -> 返回该目录的直接子项列表,agent 可逐级深入。
27+
* - path 为文件 -> 返回文件内容 (Base64 解码)。
28+
*
29+
* token 非空时带 Authorization 头 (5000 次/小时),否则匿名 (60 次/小时)。
30+
*/
31+
class GithubTool(
32+
private val token: String? = System.getenv("GITHUB_TOKEN"),
33+
) : Tool() {
34+
private val http: HttpClient = HttpClient.newHttpClient()
35+
private val json = Json { ignoreUnknownKeys = true }
36+
37+
override val name = "github"
38+
override val description =
39+
"Browse a GitHub repository. Built-in suggestions: 'ZenthXSin/Vicky', 'Anuken/Mindustry' " +
40+
"(but any 'owner/repo' works). Pass 'repo' and an optional 'path': if 'path' is a directory " +
41+
"(or empty = repo root) it returns that directory's immediate entries so you can drill down " +
42+
"level by level; if 'path' is a file it returns the file content. Optional 'ref' = branch/tag/commit."
43+
44+
override val parameters: JsonObject = buildJsonObject {
45+
put("type", "object")
46+
putJsonObject("properties") {
47+
putJsonObject("repo") {
48+
put("type", "string")
49+
put("description", "Repository as 'owner/repo', e.g. 'Anuken/Mindustry'.")
50+
}
51+
putJsonObject("path") {
52+
put("type", "string")
53+
put("description", "Directory or file path inside the repo. Empty = repo root.")
54+
}
55+
putJsonObject("ref") {
56+
put("type", "string")
57+
put("description", "Branch/tag/commit. Omit to use the repo default branch.")
58+
}
59+
}
60+
put("required", buildJsonArray { add(kotlinx.serialization.json.JsonPrimitive("repo")) })
61+
}
62+
63+
override suspend fun execute(userId: String, args: JsonObject): ToolResult {
64+
val repo = args["repo"]?.jsonPrimitive?.content?.trim()?.trim('/')
65+
?: return ToolResult(toAgent = "Error: missing 'repo'.")
66+
val parts = repo.split("/")
67+
if (parts.size != 2 || parts.any { it.isBlank() }) {
68+
return ToolResult(toAgent = "Error: 'repo' must be 'owner/repo', got '$repo'.")
69+
}
70+
val (owner, name) = parts
71+
val path = args["path"]?.jsonPrimitive?.content?.trim()?.trim('/').orEmpty()
72+
val ref = args["ref"]?.jsonPrimitive?.content?.trim()?.takeIf { it.isNotEmpty() }
73+
74+
return runCatching { fetch(owner, name, path, ref) }
75+
.getOrElse { ToolResult(toAgent = "Error accessing GitHub: ${it.message}") }
76+
}
77+
78+
private suspend fun fetch(owner: String, name: String, path: String, ref: String?): ToolResult {
79+
val encodedPath = path.split("/").filter { it.isNotEmpty() }
80+
.joinToString("/") { URLEncoder.encode(it, Charsets.UTF_8).replace("+", "%20") }
81+
val base = "https://api.github.com/repos/$owner/$name/contents/$encodedPath"
82+
val url = if (ref != null) "$base?ref=${URLEncoder.encode(ref, Charsets.UTF_8)}" else base
83+
84+
val reqBuilder = HttpRequest.newBuilder(URI.create(url))
85+
.header("Accept", "application/vnd.github+json")
86+
.header("User-Agent", "Vicky")
87+
.header("X-GitHub-Api-Version", "2022-11-28")
88+
.GET()
89+
if (!token.isNullOrBlank()) reqBuilder.header("Authorization", "Bearer $token")
90+
91+
val resp = withContext(Dispatchers.IO) {
92+
http.send(reqBuilder.build(), HttpResponse.BodyHandlers.ofString())
93+
}
94+
95+
if (resp.statusCode() !in 200..299) {
96+
return ToolResult(toAgent = errorFor(resp, "$owner/$name", path))
97+
}
98+
99+
val element = json.parseToJsonElement(resp.body())
100+
return when {
101+
element is JsonArray -> ToolResult(toAgent = formatListing(owner, name, path, element))
102+
element is JsonObject -> formatFile(element)
103+
else -> ToolResult(toAgent = "Error: unexpected GitHub response.")
104+
}
105+
}
106+
107+
private fun formatListing(owner: String, name: String, path: String, arr: JsonArray): String {
108+
val here = if (path.isEmpty()) "(root)" else path
109+
val entries = arr.mapNotNull { it as? JsonObject }
110+
.map { o ->
111+
val type = o["type"]?.jsonPrimitive?.content ?: "?"
112+
val entryName = o["name"]?.jsonPrimitive?.content ?: "?"
113+
val size = o["size"]?.jsonPrimitive?.int ?: 0
114+
Triple(type, entryName, size)
115+
}
116+
.sortedWith(compareBy({ if (it.first == "dir") 0 else 1 }, { it.second.lowercase() }))
117+
118+
if (entries.isEmpty()) return "$owner/$name : $here is empty."
119+
120+
val lines = entries.joinToString("\n") { (type, entryName, size) ->
121+
if (type == "dir") "[dir] $entryName" else "[file] $entryName ($size B)"
122+
}
123+
return "$owner/$name : listing of $here\n$lines"
124+
}
125+
126+
private fun formatFile(obj: JsonObject): ToolResult {
127+
val type = obj["type"]?.jsonPrimitive?.content
128+
if (type != "file") {
129+
return ToolResult(toAgent = "Error: path is not a file (type=$type).")
130+
}
131+
val filePath = obj["path"]?.jsonPrimitive?.content ?: "?"
132+
val size = obj["size"]?.jsonPrimitive?.int ?: 0
133+
val encoding = obj["encoding"]?.jsonPrimitive?.content
134+
val rawContent = obj["content"]?.jsonPrimitive?.content.orEmpty()
135+
136+
if (encoding != "base64" || rawContent.isBlank()) {
137+
val download = obj["download_url"]?.jsonPrimitive?.content
138+
return ToolResult(
139+
toAgent = "File '$filePath' ($size B) is too large to inline or is binary. " +
140+
(download?.let { "Download: $it" } ?: "No inline content available."),
141+
)
142+
}
143+
144+
val decoded = runCatching {
145+
String(Base64.getMimeDecoder().decode(rawContent), Charsets.UTF_8)
146+
}.getOrElse {
147+
return ToolResult(toAgent = "Error decoding '$filePath': ${it.message}")
148+
}
149+
150+
val capped = if (decoded.length > MAX_FILE_CHARS) {
151+
decoded.take(MAX_FILE_CHARS) + "\n... [truncated, ${decoded.length - MAX_FILE_CHARS} more chars]"
152+
} else {
153+
decoded
154+
}
155+
return ToolResult(toAgent = "File '$filePath' ($size B):\n$capped")
156+
}
157+
158+
private fun errorFor(resp: HttpResponse<String>, repo: String, path: String): String = when (resp.statusCode()) {
159+
404 -> "Error: '$repo' or path '${path.ifEmpty { "(root)" }}' not found (404)."
160+
403 -> "Error: GitHub returned 403 (likely rate limited; set GITHUB_TOKEN to raise the limit)."
161+
else -> "Error: GitHub returned ${resp.statusCode()}: ${resp.body().take(300)}"
162+
}
163+
164+
private companion object {
165+
const val MAX_FILE_CHARS = 20_000
166+
}
167+
}

0 commit comments

Comments
 (0)