Skip to content

Commit 07edabd

Browse files
committed
✨ [AI]: Add useAgent hook and tool calling support
- Add useAgent hook for multi-step tool calling loops - Extend useChat with tools and toolChoice options - Add tool calling support for OpenAI and Anthropic providers - Add new stream events: ToolCallDelta, ReasoningDelta, Multi - Add assistant message content parts: ToolCallPart, ReasoningPart - Update documentation with useAgent example
1 parent 6f4c23a commit 07edabd

16 files changed

Lines changed: 1626 additions & 17 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ implementation("xyz.junerver.compose:ai:<latest_release>")
162162
| hook name | description |
163163
| --------- | ----------- |
164164
| [useChat](https://github.com/junerver/ComposeHooks/blob/master/app/src/commonMain/kotlin/xyz/junerver/composehooks/example/UseChatExample.kt) | A hook for managing chat conversations with OpenAI-compatible APIs and Anthropic Messages API, supporting streaming responses with typewriter effect. |
165+
| [useAgent](https://github.com/junerver/ComposeHooks/blob/master/app/src/commonMain/kotlin/xyz/junerver/composehooks/example/UseAgentExample.kt) | A hook for multi-step tool calling (agent loop) built on top of useChat. |
165166
| [useGenerateObject](https://github.com/junerver/ComposeHooks/blob/master/app/src/commonMain/kotlin/xyz/junerver/composehooks/example/UseGenerateObjectExample.kt) | A hook for generating structured data objects from AI responses, supports streaming and incremental object updates. |
166167

167168
**Features:**

README.zh-CN.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ implementation("xyz.junerver.compose:hai:<latest_release>")
169169
| Hook 名称 | 描述 |
170170
| --------- | ---- |
171171
| [useChat](https://github.com/junerver/ComposeHooks/blob/master/app/src/commonMain/kotlin/xyz/junerver/composehooks/example/UseChatExample.kt) | 用于管理与 OpenAI 兼容 API 和 Anthropic Messages API 聊天对话的 Hook,支持流式响应的打字机效果。 |
172+
| [useAgent](https://github.com/junerver/ComposeHooks/blob/master/app/src/commonMain/kotlin/xyz/junerver/composehooks/example/UseAgentExample.kt) | 用于多轮工具调用(agent loop)的 Hook(构建在 useChat 之上)。 |
172173
| [useGenerateObject](https://github.com/junerver/ComposeHooks/blob/master/app/src/commonMain/kotlin/xyz/junerver/composehooks/example/UseGenerateObjectExample.kt) | 用于从 AI 响应生成结构化数据对象的 Hook,支持流式与增量更新对象。 |
173174

174175
**功能特性:**
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
package xyz.junerver.compose.ai.useagent
2+
3+
import kotlinx.coroutines.async
4+
import kotlinx.coroutines.awaitAll
5+
import kotlinx.coroutines.coroutineScope
6+
import kotlinx.serialization.json.JsonElement
7+
import kotlinx.serialization.json.JsonObject
8+
import kotlinx.serialization.json.JsonPrimitive
9+
import kotlinx.serialization.json.buildJsonObject
10+
import kotlinx.serialization.json.jsonObject
11+
import xyz.junerver.compose.ai.usechat.AssistantMessage
12+
import xyz.junerver.compose.ai.usechat.ChatClient
13+
import xyz.junerver.compose.ai.usechat.ChatMessage
14+
import xyz.junerver.compose.ai.usechat.ChatResponseResult
15+
import xyz.junerver.compose.ai.usechat.Providers
16+
import xyz.junerver.compose.ai.usechat.ReasoningPart
17+
import xyz.junerver.compose.ai.usechat.TextPart
18+
import xyz.junerver.compose.ai.usechat.ToolCallPart
19+
import xyz.junerver.compose.ai.usechat.ToolMessage
20+
import xyz.junerver.compose.ai.usechat.assistantMessage
21+
import xyz.junerver.compose.ai.usechat.toolMessage
22+
import xyz.junerver.compose.ai.usechat.StreamEvent
23+
24+
/*
25+
Description: Agent loop for tool calling + multi-turn chat
26+
Author: Junerver
27+
Date: 2026/01/07
28+
Email: junerver@gmail.com
29+
Version: v1.0
30+
*/
31+
32+
internal data class AgentStepResult(
33+
val assistant: AssistantMessage,
34+
val toolMessages: List<ToolMessage>,
35+
)
36+
37+
internal suspend fun runAgentLoop(
38+
client: ChatClient,
39+
messages: MutableList<ChatMessage>,
40+
tools: List<Tool<*>>,
41+
maxSteps: Int,
42+
parallelToolCalls: Boolean,
43+
stream: Boolean,
44+
model: String,
45+
onAssistant: suspend (ChatResponseResult) -> Unit,
46+
onAssistantPartial: suspend (AssistantMessage) -> Unit = { },
47+
onToolMessage: suspend (ToolMessage) -> Unit,
48+
): Unit {
49+
require(maxSteps > 0) { "maxSteps must be > 0" }
50+
51+
var steps = 0
52+
while (true) {
53+
if (steps++ >= maxSteps) throw IllegalStateException("Agent exceeded maxSteps=$maxSteps")
54+
55+
val response = if (stream) {
56+
streamChatToResult(
57+
client = client,
58+
messages = messages.toList(),
59+
model = model,
60+
onAssistantPartial = { msg ->
61+
if (messages.isNotEmpty() && messages.last() is AssistantMessage) {
62+
messages[messages.lastIndex] = msg
63+
} else {
64+
messages += msg
65+
}
66+
onAssistantPartial(msg)
67+
},
68+
)
69+
} else {
70+
client.chat(messages).also { messages += it.message }
71+
}
72+
onAssistant(response)
73+
74+
val toolCalls = response.message.toolCalls
75+
if (toolCalls.isEmpty()) return
76+
77+
val toolMessages = executeToolCalls(
78+
toolCalls = toolCalls,
79+
tools = tools,
80+
parallel = parallelToolCalls,
81+
)
82+
messages += toolMessages
83+
toolMessages.forEach { onToolMessage(it) }
84+
}
85+
}
86+
87+
private suspend fun streamChatToResult(
88+
client: ChatClient,
89+
messages: List<ChatMessage>,
90+
model: String,
91+
onAssistantPartial: suspend (AssistantMessage) -> Unit,
92+
): ChatResponseResult {
93+
var accumulatedText = ""
94+
var accumulatedReasoning = ""
95+
var lastUsage: xyz.junerver.compose.ai.usechat.ChatUsage? = null
96+
var lastFinishReason: xyz.junerver.compose.ai.usechat.FinishReason? = null
97+
98+
data class ToolCallBuilder(
99+
var toolCallId: String? = null,
100+
var toolName: String? = null,
101+
val args: StringBuilder = StringBuilder(),
102+
)
103+
104+
val toolCallBuilders = linkedMapOf<Int, ToolCallBuilder>()
105+
106+
fun buildContentParts(): List<xyz.junerver.compose.ai.usechat.AssistantContentPart> {
107+
val parts = mutableListOf<xyz.junerver.compose.ai.usechat.AssistantContentPart>()
108+
109+
if (accumulatedText.isNotEmpty() || (toolCallBuilders.isEmpty() && accumulatedReasoning.isEmpty())) {
110+
parts += TextPart(accumulatedText)
111+
} else {
112+
parts += TextPart("")
113+
}
114+
115+
if (accumulatedReasoning.isNotEmpty()) {
116+
parts += ReasoningPart(accumulatedReasoning)
117+
}
118+
119+
toolCallBuilders.entries.sortedBy { it.key }.forEach { (index, builder) ->
120+
val toolCallId = builder.toolCallId ?: "toolcall_$index"
121+
val toolName = builder.toolName ?: "tool"
122+
val argsJson: JsonObject = try {
123+
val raw = builder.args.toString()
124+
if (raw.isBlank()) {
125+
buildJsonObject { }
126+
} else {
127+
Providers.json.parseToJsonElement(raw).jsonObject
128+
}
129+
} catch (_: Exception) {
130+
buildJsonObject { }
131+
}
132+
parts += ToolCallPart(
133+
toolCallId = toolCallId,
134+
toolName = toolName,
135+
args = argsJson,
136+
)
137+
}
138+
139+
return parts
140+
}
141+
142+
var assistant = assistantMessage(
143+
text = "",
144+
model = model,
145+
)
146+
onAssistantPartial(assistant)
147+
148+
client.streamChat(messages).collect { event ->
149+
when (event) {
150+
is StreamEvent.Delta -> {
151+
accumulatedText += event.content
152+
event.finishReason?.let { lastFinishReason = xyz.junerver.compose.ai.usechat.FinishReason.fromString(it) }
153+
event.usage?.let { lastUsage = it }
154+
}
155+
156+
is StreamEvent.ReasoningDelta -> {
157+
accumulatedReasoning += event.text
158+
}
159+
160+
is StreamEvent.ToolCallDelta -> {
161+
val builder = toolCallBuilders.getOrPut(event.index) { ToolCallBuilder() }
162+
if (!event.toolCallId.isNullOrBlank()) builder.toolCallId = event.toolCallId
163+
if (!event.toolName.isNullOrBlank()) builder.toolName = event.toolName
164+
if (!event.argumentsDelta.isNullOrEmpty()) builder.args.append(event.argumentsDelta)
165+
}
166+
167+
is StreamEvent.Done -> return@collect
168+
is StreamEvent.Error -> throw event.error
169+
is StreamEvent.Multi -> Unit
170+
}
171+
172+
assistant = assistant.copy(
173+
content = buildContentParts(),
174+
model = model,
175+
usage = lastUsage,
176+
finishReason = lastFinishReason,
177+
)
178+
onAssistantPartial(assistant)
179+
}
180+
181+
return ChatResponseResult(
182+
message = assistant,
183+
usage = lastUsage,
184+
finishReason = lastFinishReason,
185+
)
186+
}
187+
188+
private suspend fun executeToolCalls(
189+
toolCalls: List<ToolCallPart>,
190+
tools: List<Tool<*>>,
191+
parallel: Boolean,
192+
): List<ToolMessage> = if (parallel && toolCalls.size > 1) {
193+
coroutineScope {
194+
toolCalls.map { call ->
195+
async {
196+
executeSingleToolCall(call, tools)
197+
}
198+
}.awaitAll()
199+
}
200+
} else {
201+
toolCalls.map { call -> executeSingleToolCall(call, tools) }
202+
}
203+
204+
private suspend fun executeSingleToolCall(call: ToolCallPart, tools: List<Tool<*>>): ToolMessage {
205+
val match = tools.firstOrNull { it.name == call.toolName }
206+
if (match == null) {
207+
return toolMessage(
208+
toolCallId = call.toolCallId,
209+
toolName = call.toolName,
210+
result = JsonPrimitive("Tool not found: ${call.toolName}"),
211+
isError = true,
212+
)
213+
}
214+
215+
return try {
216+
val resultJson = executeTool(match, call.args)
217+
toolMessage(
218+
toolCallId = call.toolCallId,
219+
toolName = call.toolName,
220+
result = resultJson,
221+
isError = false,
222+
)
223+
} catch (e: Exception) {
224+
toolMessage(
225+
toolCallId = call.toolCallId,
226+
toolName = call.toolName,
227+
result = JsonPrimitive(e.message ?: "Tool execution failed"),
228+
isError = true,
229+
)
230+
}
231+
}
232+
233+
@Suppress("UNCHECKED_CAST")
234+
private suspend fun executeTool(tool: Tool<*>, args: JsonObject): JsonElement = (tool as Tool<Any?>).executeWithJson(args)

0 commit comments

Comments
 (0)