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
3 changes: 3 additions & 0 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Engineering Principles

**Keep code simple: avoid overly long files and functions to manage complexity.** When a file exceeds ~400 lines or a function exceeds ~80 lines, consider splitting it. Break complex logic into small, focused units to reduce cognitive load.
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@
import io.agentscope.core.agent.EventType;
import io.agentscope.core.agent.StreamOptions;
import io.agentscope.core.memory.InMemoryMemory;
import io.agentscope.core.message.ContentBlock;
import io.agentscope.core.message.Msg;
import io.agentscope.core.message.MsgRole;
import io.agentscope.core.message.TextBlock;
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.session.Session;
import io.agentscope.core.skill.SkillBox;
import io.agentscope.core.tool.Toolkit;
Expand All @@ -30,6 +34,7 @@
import io.github.malonetalk.agent.skill.SkillLoaderService;
import io.github.malonetalk.agent.tools.MarkAgentTool;
import io.github.malonetalk.convertor.EventConverter;
import io.github.malonetalk.dto.ChatRequest;
import io.github.malonetalk.dto.ChatStreamEvent;
import io.github.malonetalk.utils.MsgUtils;
import jakarta.annotation.PostConstruct;
Expand Down Expand Up @@ -75,13 +80,33 @@ public String chat(String sessionId, String userInput) {
return MsgUtils.getTextContent(response);
}

public Flux<ChatStreamEvent> chatStream(String sessionId, String userInput) {
public Flux<ChatStreamEvent> chatStream(
String sessionId, String userInput, List<ChatRequest.ToolResultInput> toolResults) {
ReActAgent agent = createAgent();

Session session = sessionService.getOrCreateSession(sessionId);
agent.loadIfExists(session, sessionId);

Msg userMsg = Msg.builder().textContent(userInput).build();
Msg userMsg;
if (toolResults != null && !toolResults.isEmpty()) {
List<ContentBlock> blocks =
toolResults.stream()
.<ContentBlock>map(
tr ->
ToolResultBlock.builder()
.id(tr.toolCallId())
.name(tr.toolName())
.output(
TextBlock.builder()
.text(tr.output())
.build())
.build())
.toList();
userMsg = Msg.builder().role(MsgRole.TOOL).content(blocks).build();
} else {
String text = userInput != null ? userInput : "";
userMsg = Msg.builder().textContent(text).build();
}

StreamOptions streamOptions =
StreamOptions.builder()
Expand All @@ -105,6 +130,7 @@ private ReActAgent createAgent() {
.skillBox(skillBox)
.memory(new InMemoryMemory())
.maxIters(10)
.enablePendingToolRecovery(true)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/*
* Copyright (C) 2026 github.com/MaloneTalk
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* limitations under the License.
*/
package io.github.malonetalk.agent.tools;

import io.agentscope.core.tool.Tool;
import io.agentscope.core.tool.ToolParam;
import io.agentscope.core.tool.ToolSuspendException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

@Slf4j
@Component
public class AskUserTool implements MarkAgentTool {

@Tool(name = "ask_user", description = "当你不确定某个操作或需要用户确认时,调用此工具向用户提问。用户回答后你会收到回复并继续执行。")
public String askUser(
@ToolParam(name = "question", description = "要向用户询问的问题") String question) {
log.info("Agent asks user: {}", question);
throw new ToolSuspendException(question);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,18 +56,8 @@ public Result<String> chat(@Valid @RequestBody ChatRequest request) {
@PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<ChatStreamEvent>> chatStream(
@Valid @RequestBody ChatRequest request) {
String sessionId = request.sessionId();
if (sessionId == null || sessionId.isEmpty()) {
sessionId = "default";
}

String message = request.message();
if (message == null) {
message = "";
}

return agentService
.chatStream(sessionId, message)
.chatStream(request.sessionId(), request.message(), request.toolResults())
.map(
event ->
ServerSentEvent.<ChatStreamEvent>builder()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,14 @@
import io.github.malonetalk.enums.ChatStreamEventType;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.extern.slf4j.Slf4j;

@Slf4j
public class EventConverter {

// TODO 2026/05/26后期优化下当前map函数的代码提高可读性和维护性
public static List<ChatStreamEvent> map(Event event) {
Msg msg = event.getMessage();
String messageId = msg.getId();
Expand Down Expand Up @@ -110,15 +112,31 @@ public static List<ChatStreamEvent> map(Event event) {
new ToolCallInfo(tub.getId(), tub.getName(), tub.getInput()),
null));
} else if (block instanceof ToolResultBlock trb) {
String outputText = extractOutputText(trb);
results.add(
new ChatStreamEvent(
ChatStreamEventType.TOOL_RESULT,
messageId,
isLast,
null,
null,
new ToolResultInfo(trb.getId(), trb.getName(), outputText)));
if (trb.isSuspended() && "ask_user".equals(trb.getName())) {
String questionText = extractOutputText(trb);
results.add(
new ChatStreamEvent(
ChatStreamEventType.QUESTION,
messageId,
isLast,
questionText,
new ToolCallInfo(trb.getId(), trb.getName(), Map.of()),
null));
} else {
String outputText = extractOutputText(trb);
results.add(
new ChatStreamEvent(
ChatStreamEventType.TOOL_RESULT,
messageId,
isLast,
null,
null,
new ToolResultInfo(
trb.getId(),
trb.getName(),
outputText,
trb.isSuspended())));
}
} else if (block instanceof TextBlock tb) {
String text = tb.getText();
if (text != null && !text.isEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,12 @@
package io.github.malonetalk.dto;

import jakarta.validation.constraints.NotBlank;
import java.util.List;

public record ChatRequest(
@NotBlank(message = "sessionId 不能为空") String sessionId,
@NotBlank(message = "message 不能为空") String message) {}
String message,
List<ToolResultInput> toolResults) {

public record ToolResultInput(String toolCallId, String toolName, String output) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,5 +32,10 @@ public record ChatStreamEvent(

public record ToolCallInfo(String id, String name, Map<String, Object> input) {}

public record ToolResultInfo(String id, String name, String output) {}
public record ToolResultInfo(String id, String name, String output, boolean suspended) {

public ToolResultInfo(String id, String name, String output) {
this(id, name, output, false);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ public enum ChatStreamEventType {
TOOL_CALL("tool_call"),
TOOL_RESULT("tool_result"),
THINKING("thinking"),
TEXT("text");
TEXT("text"),
QUESTION("question");

private final String code;

Expand Down
17 changes: 15 additions & 2 deletions data-agent-frontend/src/api/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/

export type ChatStreamEventType = 'summary' | 'tool_call' | 'tool_result' | 'thinking' | 'text';
export type ChatStreamEventType =
| 'summary'
| 'tool_call'
| 'tool_result'
| 'thinking'
| 'text'
| 'question';

export interface ToolCallInfo {
id: string;
Expand All @@ -29,6 +35,12 @@ export interface ToolResultInfo {
output: string;
}

export interface ToolResultInput {
toolCallId: string;
toolName: string;
output: string;
}

export interface ChatStreamEvent {
type: ChatStreamEventType;
messageId: string | null;
Expand All @@ -47,7 +59,8 @@ export interface SessionInfo {

export interface ChatRequest {
sessionId: string;
message: string;
message?: string;
toolResults?: ToolResultInput[];
}

export interface TurnItem {
Expand Down
34 changes: 33 additions & 1 deletion data-agent-frontend/src/components/chat/ChatInput.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@
<script setup lang="ts">
import { ref, nextTick } from 'vue';

import type { PendingQuestion } from '@/composables/useAgentChat';

const props = defineProps<{
isStreaming: boolean;
pendingQuestion: PendingQuestion | null;
}>();

const emit = defineEmits<{
Expand Down Expand Up @@ -63,11 +66,21 @@

<template>
<div class="chat-input">
<div v-if="props.pendingQuestion && !isStreaming" class="chat-input__question-banner">
<span class="chat-input__question-label">Agent 提问:</span>
<span class="chat-input__question-text">{{ props.pendingQuestion.question }}</span>
</div>
<textarea
ref="textareaRef"
v-model="inputText"
class="chat-input__textarea"
:placeholder="isStreaming ? '正在回复中...' : '输入消息,Enter 发送,Shift+Enter 换行'"
:placeholder="
props.pendingQuestion && !isStreaming
? '输入你的回答,Enter 发送...'
: isStreaming
? '正在回复中...'
: '输入消息,Enter 发送,Shift+Enter 换行'
"
:disabled="isStreaming"
rows="1"
@input="autoResize"
Expand All @@ -91,13 +104,32 @@
<style scoped>
.chat-input {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 10px;
padding: 16px 20px;
background: #fff;
border-top: 1px solid #e5e7eb;
}

.chat-input__question-banner {
width: 100%;
padding: 8px 14px;
background: #fffbeb;
border: 1px solid #fcd34d;
border-radius: 8px;
font-size: 13px;
color: #92400e;
}

.chat-input__question-label {
font-weight: 600;
}

.chat-input__question-text {
color: #78350f;
}

.chat-input__textarea {
flex: 1;
resize: none;
Expand Down
Loading
Loading