Skip to content

Commit 1633290

Browse files
committed
feat(agent): 增加上下文管理与内置文件工具支持
- 新增 ContextCompactor 实现上下文轮数裁剪和 LLM 压缩摘要功能 - 在 Agent 中集成 ContextCompactor,确保上下文符合预算限制 - 扩展 BuiltinTools 支持基于项目根目录的文件读写和列表工具 - 实现 FileReadTool、FileWriteTool、FileListTool,保障路径安全和操作权限 - OneBot 扩展管理工具权限,新增文件写入权限控制 - ConsoleMain 优化配置加载方式,支持动态配置与工具注册 - 更新.gitignore及IDE相关配置,忽略新增配置文件和目录
1 parent 0b36c47 commit 1633290

10 files changed

Lines changed: 411 additions & 139 deletions

File tree

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ build/
44
!**/src/main/**/build/
55
!**/src/test/**/build/
66
other
7+
config
78

89
### IntelliJ IDEA ###
910
.idea/modules.xml
@@ -41,6 +42,9 @@ bin/
4142

4243
### VS Code ###
4344
.vscode/
45+
.idea/
46+
.claude/
47+
.mimocode/
4448

4549
### Mac OS ###
4650
.DS_Store

.idea/gradle.xml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

.idea/vcs.xml

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import com.aallam.openai.api.chat.ToolType
1010
import com.aallam.openai.client.OpenAI
1111
import kotlinx.serialization.json.Json
1212
import org.example.vicky.context.ContextBuilder
13+
import org.example.vicky.context.ContextCompactor
1314
import org.example.vicky.context.ConversationStore
1415
import org.example.vicky.io.InboundMessage
1516
import org.example.vicky.io.MessageSink
@@ -52,9 +53,13 @@ abstract class Agent(
5253
protected val store: ConversationStore = ConversationStore(),
5354
) {
5455
val tools: ToolRegistry = ToolRegistry()
56+
private val compactor = ContextCompactor(config, openAi)
5557

5658
init {
57-
if (config.builtinTools) BuiltinTools.all().forEach { tools.register(it) }
59+
if (config.builtinTools) {
60+
val baseDir = java.io.File(System.getProperty("user.dir"))
61+
BuiltinTools.all(baseDir).forEach { tools.register(it) }
62+
}
5863
}
5964

6065
/** 子类提供:消息出口。 */
@@ -104,6 +109,8 @@ abstract class Agent(
104109
// 模式决定是否传工具(如 CHAT 不传)。
105110
val oaiTools = if (config.mode.toolsEnabled) buildOpenAiTools() else emptyList()
106111

112+
compactor.ensureContextBudget(history)
113+
107114
repeat(config.maxSteps) { step ->
108115
log("step ${step + 1}/${config.maxSteps} -> requesting completion (${history.size} msgs)")
109116
val request = ChatCompletionRequest(
@@ -152,6 +159,7 @@ abstract class Agent(
152159
if (result.endTurn) endTurn = true
153160
}
154161
compactOldToolRounds(history)
162+
compactor.ensureContextBudget(history)
155163
if (endTurn) {
156164
log("step ${step + 1}: endTurn signaled, finishing turn")
157165
if (clearContextAfter) store.clear(msg.conversationId)
@@ -242,4 +250,4 @@ abstract class Agent(
242250
const val KEEP_RECENT_TOOL_ROUNDS = 2
243251
const val SUMMARY_PREFIX = "[summary] "
244252
}
245-
}
253+
}

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

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
package org.example.vicky.agent
1+
package org.example.vicky.agent
22

33
import com.aallam.openai.api.model.ModelId
44

@@ -9,6 +9,8 @@ import com.aallam.openai.api.model.ModelId
99
* @property apiKey API key (可空仅用于 mock 测试)。
1010
* @property baseUrl 兼容 OpenAI 协议的自定义 host,例如 "https://api.deepseek.com/v1/"。null = 官方。
1111
* @property maxSteps 单次 receive 最多走多少轮 LLM 推理,防死循环。
12+
* @property maxMemoryRounds 最多保留多少轮用户消息(1轮 = user + assistant),超过则截断旧消息。0 = 不限制。
13+
* @property maxContextLength 上下文总字符数上限,超过则触发 LLM 压缩生成摘要。0 = 不限制。
1214
* @property mode 模式 1 (SILENT) 或模式 2 (VERBOSE)。
1315
* @property temperature 透传给 chat completion。
1416
* @property agentMd 基础系统提示文本 (人设/指令),直接内联,不再读文件。
@@ -28,4 +30,4 @@ data class AgentConfig(
2830
val debug: Boolean = false,
2931
val think: Boolean = false,
3032
val builtinTools: Boolean = true,
31-
)
33+
)

src/main/kotlin/org/example/vicky/channel/onebot/OneBot.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ class OneBot(
5353
agent.registerTool(GroupMembersTool(bot!!))
5454
agent.registerTool(UserProfileTool(bot!!))
5555
// Register mirai L2 tools (write operations, admin-gated)
56-
listOf("send_message", "group_manage", "friend_manage", "group_quit", "group_announcements")
56+
listOf("send_message", "group_manage", "friend_manage", "group_quit", "group_announcements", "file_write")
5757
.forEach { adminToolList.add(it) }
5858
agent.registerTool(SendMessageTool(bot!!))
5959
agent.registerTool(GroupManageTool(bot!!))
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package org.example.vicky.context
2+
3+
import com.aallam.openai.api.chat.ChatCompletionRequest
4+
import com.aallam.openai.api.chat.ChatMessage
5+
import com.aallam.openai.api.chat.ChatRole
6+
import com.aallam.openai.client.OpenAI
7+
import org.example.vicky.agent.AgentConfig
8+
9+
/**
10+
* 上下文压缩器,提供两级上下文管理:
11+
*
12+
* 1. **轮数裁剪** ([maxMemoryRounds]):保留最近 N 轮用户消息,截断更早的消息。
13+
* 2. **LLM 压缩** ([maxContextLength]):当估算 token 超限时,调用 LLM 生成结构化摘要,替换旧消息。
14+
*
15+
* 在每次 LLM 请求前调用 [ensureContextBudget] 执行两级检查。
16+
*/
17+
class ContextCompactor(
18+
private val config: AgentConfig,
19+
private val openAi: OpenAI,
20+
) {
21+
companion object {
22+
private const val CHARS_PER_TOKEN = 4
23+
private const val KEEP_RECENT_MESSAGES = 4
24+
private const val SUMMARY_MARKER = "[context-summary]"
25+
26+
const val COMPRESSION_PROMPT = """请将以下对话历史压缩为结构化摘要,保留关键信息。使用以下格式,空的部分写"N/A":
27+
28+
## 初始请求
29+
用户的原始目标或请求。
30+
31+
## 已完成步骤
32+
- 按顺序列出每个已执行的操作、调用的工具、做出的决定。
33+
34+
## 当前状态
35+
目前已完成的工作,涉及的文件/代码/数据修改。
36+
37+
## 待办任务
38+
仍需完成的工作。
39+
40+
## 技术决策
41+
关键的架构、设计或实现选择。
42+
43+
## 涉及文件
44+
所有读取、写入、引用的文件路径。
45+
46+
## 遇到的错误
47+
错误及解决方案(或未解决的情况)。
48+
49+
## 环境信息
50+
相关上下文:OS、语言版本、框架版本、项目结构。
51+
52+
## 下一步建议
53+
建议的后续操作。
54+
55+
---
56+
57+
对话内容:
58+
{conversation}"""
59+
}
60+
61+
/**
62+
* 主入口:在每次 LLM 请求前调用。
63+
* 依次执行轮数裁剪和 LLM 压缩。
64+
*/
65+
suspend fun ensureContextBudget(history: MutableList<ChatMessage>) {
66+
if (config.maxMemoryRounds > 0) {
67+
trimMemoryRounds(history)
68+
}
69+
if (config.maxContextLength > 0) {
70+
compressToContextBudget(history)
71+
}
72+
}
73+
74+
// ── 机制一:轮数裁剪 ──────────────────────────────────────
75+
76+
private fun trimMemoryRounds(history: MutableList<ChatMessage>) {
77+
val systemMsg = history.firstOrNull { it.role == ChatRole.System }
78+
val nonSystemMessages = history.filter { it.role != ChatRole.System }
79+
80+
val userMessageIndices = nonSystemMessages
81+
.mapIndexedNotNull { i, m -> if (m.role == ChatRole.User) i else null }
82+
83+
if (userMessageIndices.size <= config.maxMemoryRounds) return
84+
85+
val roundsToTrim = userMessageIndices.size - config.maxMemoryRounds
86+
val cutoffNonSystemIndex = userMessageIndices[roundsToTrim]
87+
val cutoffHistoryIndex = cutoffNonSystemIndex + if (systemMsg != null) 1 else 0
88+
89+
val kept = history.subList(cutoffHistoryIndex, history.size).toMutableList()
90+
history.clear()
91+
if (systemMsg != null) history.add(systemMsg)
92+
history.addAll(kept)
93+
}
94+
95+
// ── 机制二:LLM 压缩 ──────────────────────────────────────
96+
97+
private suspend fun compressToContextBudget(history: MutableList<ChatMessage>) {
98+
val totalTokens = history.sumOf { it.estimateChars() } / CHARS_PER_TOKEN
99+
if (totalTokens <= config.maxContextLength) return
100+
101+
val systemMsg = if (history.firstOrNull()?.role == ChatRole.System) history.first() else null
102+
val preserveEnd = if (systemMsg != null) 1 else 0
103+
val totalNonSystem = history.size - preserveEnd
104+
if (totalNonSystem <= KEEP_RECENT_MESSAGES) return
105+
106+
val compressStart = preserveEnd
107+
// 向后推进 compressEnd 跨过任何 Tool 消息,避免它们与压缩区内的 Assistant.toolCalls 失配
108+
var compressEnd = history.size - KEEP_RECENT_MESSAGES
109+
while (compressEnd < history.size && history[compressEnd].role == ChatRole.Tool) {
110+
compressEnd++
111+
}
112+
if (compressEnd <= compressStart) return
113+
114+
val toCompress = history.subList(compressStart, compressEnd).map { it.deepCopy() }
115+
val toKeepRecent = history.subList(compressEnd, history.size).map { it.deepCopy() }
116+
117+
val summary = callLLMForSummary(toCompress)
118+
119+
history.clear()
120+
if (systemMsg != null) history.add(systemMsg)
121+
history.add(
122+
ChatMessage(
123+
role = ChatRole.System,
124+
content = "$SUMMARY_MARKER\n$summary",
125+
),
126+
)
127+
history.addAll(toKeepRecent)
128+
}
129+
130+
private suspend fun callLLMForSummary(messages: List<ChatMessage>): String {
131+
val formattedMessages = messages.joinToString("\n\n") { msg ->
132+
val role = when (msg.role) {
133+
ChatRole.User -> "User"
134+
ChatRole.Assistant -> "Assistant"
135+
ChatRole.Tool -> "Tool(${msg.name ?: "?"})"
136+
else -> msg.role.toString()
137+
}
138+
val content = msg.content?.take(2000) ?: "(empty)"
139+
"[$role]: $content"
140+
}
141+
142+
val prompt = COMPRESSION_PROMPT.replace("{conversation}", formattedMessages)
143+
144+
val request = ChatCompletionRequest(
145+
model = config.model,
146+
messages = listOf(
147+
ChatMessage(role = ChatRole.System, content = "You are a conversation summarizer."),
148+
ChatMessage(role = ChatRole.User, content = prompt),
149+
),
150+
temperature = 0.0,
151+
)
152+
153+
return try {
154+
openAi.chatCompletion(request).choices.first().message.content
155+
?: "(summary generation returned empty)"
156+
} catch (e: Exception) {
157+
"(summary generation failed: ${e.message})"
158+
}
159+
}
160+
161+
private fun ChatMessage.estimateChars(): Int {
162+
val contentLen = (content ?: "").length
163+
val toolCallsLen = toolCalls?.sumOf { tc ->
164+
when (tc) {
165+
is com.aallam.openai.api.chat.ToolCall.Function -> tc.function.arguments.length
166+
else -> 0
167+
}
168+
} ?: 0
169+
return contentLen + toolCallsLen
170+
}
171+
172+
private fun ChatMessage.deepCopy(): ChatMessage = ChatMessage(
173+
role = role,
174+
content = content,
175+
toolCalls = toolCalls,
176+
toolCallId = toolCallId,
177+
name = name,
178+
)
179+
}

0 commit comments

Comments
 (0)