mirror of
https://gitee.com/dromara/liteFlow.git
synced 2026-06-10 03:07:32 +08:00
feat: add default system prompt, shell safety checks, and deterministic test models
- Add DEFAULT_SYSTEM_PROMPT and effectiveSystemPrompt() to ReActAgentComponent - Block unsupported shell syntax (pipes, redirections, chaining) in ManagedShellCommandTool - Fix process I/O to prevent hangs when command waits for stdin - Fix missing sessionId in ReActLoggingHook result log line - Introduce FakeEchoModel for deterministic local testing - Add unit tests for ReActAgentComponent, ReActLoggingHook, and shell syntax rejection - Remove LiveTestSupport credential skip calls from feature tests Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -38,5 +38,23 @@
|
||||
<artifactId>hutool-core</artifactId>
|
||||
<version>${hutool.version}</version>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
<plugins>
|
||||
<plugin>
|
||||
<groupId>org.apache.maven.plugins</groupId>
|
||||
<artifactId>maven-surefire-plugin</artifactId>
|
||||
<configuration>
|
||||
<skipTests>false</skipTests>
|
||||
</configuration>
|
||||
</plugin>
|
||||
</plugins>
|
||||
</build>
|
||||
</project>
|
||||
|
||||
@@ -91,6 +91,16 @@ public abstract class ReActAgentComponent extends NodeComponent {
|
||||
/** 默认从 chain requestData 中读取 conversationId 时使用的约定 key。 */
|
||||
public static final String CONVERSATION_ID_REQUEST_KEY = "conversationId";
|
||||
|
||||
/**
|
||||
* 框架统一系统提示词。子类 {@link #systemPrompt()} 返回的内容会追加在该提示词之后。
|
||||
*/
|
||||
public static final String DEFAULT_SYSTEM_PROMPT = """
|
||||
你是 LiteFlow ReAct Agent 助手。
|
||||
请使用用户提问所用的语言回答,除非用户明确要求使用其他语言。
|
||||
每次调用工具前,先用一两句话简短说明当前判断和下一步动作,便于日志观察可见推理摘要。
|
||||
不要展开隐藏思维链,只输出面向用户和调试日志都可读的简短说明。
|
||||
""";
|
||||
|
||||
/** 在 Slot attachment 上存储 ctx 时使用的 key 前缀,按 nodeId 隔离。 */
|
||||
private static final String CTX_KEY_PREFIX = "_react_agent_ctx_";
|
||||
|
||||
@@ -155,10 +165,21 @@ public abstract class ReActAgentComponent extends NodeComponent {
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回 agent 的系统提示词。构建 agent 时只调用一次。
|
||||
* 返回组件自定义系统提示词。构建 agent 时只调用一次,并追加在框架统一系统提示词之后。
|
||||
*/
|
||||
protected abstract String systemPrompt();
|
||||
|
||||
/**
|
||||
* 返回传递给底层 ReActAgent 的最终系统提示词。
|
||||
*/
|
||||
protected final String effectiveSystemPrompt() {
|
||||
String customPrompt = systemPrompt();
|
||||
if (customPrompt == null || customPrompt.isBlank()) {
|
||||
return DEFAULT_SYSTEM_PROMPT.strip();
|
||||
}
|
||||
return DEFAULT_SYSTEM_PROMPT.strip() + "\n\n" + customPrompt.strip();
|
||||
}
|
||||
|
||||
/**
|
||||
* 返回本次执行的用户提示词。每次 {@link #process()} 都会调用。
|
||||
*/
|
||||
@@ -248,7 +269,10 @@ public abstract class ReActAgentComponent extends NodeComponent {
|
||||
}
|
||||
|
||||
protected void handleReply(Msg reply) {
|
||||
ctx().getSlot().setResponseData(reply == null ? null : reply.getTextContent());
|
||||
if (reply == null || reply.getTextContent() == null) {
|
||||
return;
|
||||
}
|
||||
ctx().getSlot().setResponseData(reply.getTextContent());
|
||||
}
|
||||
|
||||
/* ===== 框架 final 执行体 ===== */
|
||||
@@ -417,7 +441,7 @@ public abstract class ReActAgentComponent extends NodeComponent {
|
||||
|
||||
ReActAgent.Builder builder = ReActAgent.builder()
|
||||
.name(getNodeId() == null ? "liteflow-agent" : getNodeId())
|
||||
.sysPrompt(systemPrompt())
|
||||
.sysPrompt(effectiveSystemPrompt())
|
||||
.model(buildModel())
|
||||
.toolkit(toolkit)
|
||||
.memory(new InMemoryMemory())
|
||||
|
||||
@@ -77,7 +77,7 @@ public class ReActLoggingHook implements Hook {
|
||||
ToolResultBlock r = e.getToolResult();
|
||||
String result = blocksToString(r);
|
||||
LOG.info("[agent:act][{}] <<< {} 结果:", sessionId, r.getName());
|
||||
LOG.info("[agent:act][...] {}", truncate(result, MAX_RESULT_LEN));
|
||||
LOG.info("[agent:act][{}] {}", sessionId, truncate(result, MAX_RESULT_LEN));
|
||||
} else if (event instanceof ErrorEvent e) {
|
||||
LOG.warn("[agent:error][{}] {}", sessionId, e.getError().toString(), e.getError());
|
||||
}
|
||||
|
||||
@@ -13,7 +13,12 @@ import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.TimeoutException;
|
||||
|
||||
public class ManagedShellCommandTool {
|
||||
|
||||
@@ -36,6 +41,9 @@ public class ManagedShellCommandTool {
|
||||
if (command == null || command.isBlank()) {
|
||||
return "{\"error\":\"empty command\"}";
|
||||
}
|
||||
if (containsUnsupportedShellSyntax(command)) {
|
||||
return "{\"error\":\"unsupported shell syntax: pipes, redirection, and command chaining are not supported\"}";
|
||||
}
|
||||
String[] tokens = command.trim().split("\\s+");
|
||||
String first = tokens[0];
|
||||
if (shell.getMode() == ShellMode.WHITELIST && !shell.getWhitelist().contains(first)) {
|
||||
@@ -49,19 +57,57 @@ public class ManagedShellCommandTool {
|
||||
pb.directory(workspace.toFile());
|
||||
pb.redirectErrorStream(true);
|
||||
Process p = pb.start();
|
||||
String out = readLimited(p.getInputStream(), shell.getMaxOutputBytes());
|
||||
boolean done = p.waitFor(shell.getTimeout().toMillis(), TimeUnit.MILLISECONDS);
|
||||
if (!done) {
|
||||
p.destroyForcibly();
|
||||
return "{\"error\":\"timeout after " + shell.getTimeout().toMillis() + "ms\"}";
|
||||
closeQuietly(p.getOutputStream());
|
||||
ExecutorService outputReader = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "liteflow-agent-shell-output-reader");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
Future<String> outputFuture = outputReader.submit(() -> readLimited(p.getInputStream(), shell.getMaxOutputBytes()));
|
||||
try {
|
||||
boolean done = p.waitFor(shell.getTimeout().toMillis(), TimeUnit.MILLISECONDS);
|
||||
if (!done) {
|
||||
p.destroyForcibly();
|
||||
closeQuietly(p.getInputStream());
|
||||
outputFuture.cancel(true);
|
||||
return "{\"error\":\"timeout after " + shell.getTimeout().toMillis() + "ms\"}";
|
||||
}
|
||||
return outputFuture.get(1, TimeUnit.SECONDS);
|
||||
} catch (ExecutionException e) {
|
||||
Throwable cause = e.getCause();
|
||||
return "{\"error\":\"" + (cause == null ? e.getMessage() : cause.getMessage()).replace("\"", "'") + "\"}";
|
||||
} catch (TimeoutException e) {
|
||||
outputFuture.cancel(true);
|
||||
return "{\"error\":\"output read timeout\"}";
|
||||
} finally {
|
||||
outputReader.shutdownNow();
|
||||
}
|
||||
return out;
|
||||
} catch (IOException | InterruptedException e) {
|
||||
if (e instanceof InterruptedException) Thread.currentThread().interrupt();
|
||||
return "{\"error\":\"" + e.getMessage().replace("\"", "'") + "\"}";
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean containsUnsupportedShellSyntax(String command) {
|
||||
return command.contains("|")
|
||||
|| command.contains("<")
|
||||
|| command.contains(">")
|
||||
|| command.contains("&&")
|
||||
|| command.contains("||")
|
||||
|| command.contains(";");
|
||||
}
|
||||
|
||||
private static void closeQuietly(java.io.Closeable closeable) {
|
||||
if (closeable == null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
closeable.close();
|
||||
} catch (IOException ignored) {
|
||||
// ignore close failures while cleaning up process streams
|
||||
}
|
||||
}
|
||||
|
||||
private static String readLimited(InputStream in, long max) throws IOException {
|
||||
byte[] buf = new byte[4096];
|
||||
List<byte[]> chunks = new ArrayList<>();
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package com.yomahub.liteflow.agent.component;
|
||||
|
||||
import com.yomahub.liteflow.agent.model.ModelSpec;
|
||||
import com.yomahub.liteflow.slot.Slot;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class ReActAgentComponentTest {
|
||||
|
||||
@Test
|
||||
void effectiveSystemPromptPrependsFrameworkPromptAndAppendsComponentPrompt() {
|
||||
TestAgentComponent component = new TestAgentComponent();
|
||||
|
||||
String prompt = component.effectiveSystemPrompt();
|
||||
|
||||
assertTrue(prompt.contains("使用用户提问所用的语言"));
|
||||
assertTrue(prompt.contains("每次调用工具前"));
|
||||
assertTrue(prompt.contains("不要展开隐藏思维链"));
|
||||
assertTrue(prompt.contains("custom component instruction"));
|
||||
assertTrue(prompt.indexOf("使用用户提问所用的语言")
|
||||
< prompt.indexOf("custom component instruction"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void defaultHandleReplyIgnoresNullReplyInsteadOfWritingNullToSlot() {
|
||||
Slot slot = new Slot(List.of());
|
||||
TestAgentComponent component = new TestAgentComponent(slot);
|
||||
component.setNodeId("testAgent");
|
||||
slot.setAttachment("_react_agent_ctx_testAgent",
|
||||
new ReActAgentContext(slot, "cid", "testAgent", Path.of("target/test-agent")));
|
||||
|
||||
assertDoesNotThrow(() -> component.handleReplyForTest(null));
|
||||
assertNull(slot.getResponseData());
|
||||
}
|
||||
|
||||
private static class TestAgentComponent extends ReActAgentComponent {
|
||||
|
||||
private final Slot slot;
|
||||
|
||||
private TestAgentComponent() {
|
||||
this(new Slot(List.of()));
|
||||
}
|
||||
|
||||
private TestAgentComponent(Slot slot) {
|
||||
this.slot = slot;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Slot getSlot() {
|
||||
return slot;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ModelSpec<?> model() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String systemPrompt() {
|
||||
return "custom component instruction";
|
||||
}
|
||||
|
||||
@Override
|
||||
protected String userPrompt() {
|
||||
return "hello";
|
||||
}
|
||||
|
||||
void handleReplyForTest(io.agentscope.core.message.Msg reply) {
|
||||
handleReply(reply);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import com.yomahub.liteflow.flow.LiteflowResponse;
|
||||
import com.yomahub.liteflow.test.agent.cmp.RecordReplyCmp;
|
||||
import com.yomahub.liteflow.test.agent.feature.cmp.BuildModelEscapeAgentCmp;
|
||||
import com.yomahub.liteflow.test.agent.support.BaseAgentLiveTest;
|
||||
import com.yomahub.liteflow.test.agent.support.LiveTestSupport;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -29,7 +28,6 @@ public class BuildModelEscapeTest extends BaseAgentLiveTest {
|
||||
@BeforeEach
|
||||
public void reset() {
|
||||
BuildModelEscapeAgentCmp.reset();
|
||||
LiveTestSupport.ensureCompatibleCustomCredentialOrSkip(liteflowConfig, "BuildModelEscapeTest");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.yomahub.liteflow.flow.LiteflowResponse;
|
||||
import com.yomahub.liteflow.property.agent.MemoryStorageMode;
|
||||
import com.yomahub.liteflow.test.agent.feature.cmp.MemoryAgentCmp;
|
||||
import com.yomahub.liteflow.test.agent.support.BaseAgentLiveTest;
|
||||
import com.yomahub.liteflow.test.agent.support.LiveTestSupport;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -35,7 +34,6 @@ public class MemoryPersistenceTest extends BaseAgentLiveTest {
|
||||
@BeforeEach
|
||||
public void reset() {
|
||||
MemoryAgentCmp.reset();
|
||||
LiveTestSupport.ensureCompatibleCustomCredentialOrSkip(liteflowConfig, "MemoryPersistenceTest");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -4,7 +4,6 @@ import com.yomahub.liteflow.flow.LiteflowResponse;
|
||||
import com.yomahub.liteflow.test.agent.cmp.RecordReplyCmp;
|
||||
import com.yomahub.liteflow.test.agent.feature.cmp.MemoryAgentCmp;
|
||||
import com.yomahub.liteflow.test.agent.support.BaseAgentLiveTest;
|
||||
import com.yomahub.liteflow.test.agent.support.LiveTestSupport;
|
||||
import org.junit.jupiter.api.Assertions;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -30,7 +29,6 @@ public class MultiTurnMemoryTest extends BaseAgentLiveTest {
|
||||
@BeforeEach
|
||||
public void reset() {
|
||||
MemoryAgentCmp.reset();
|
||||
LiveTestSupport.ensureCompatibleCustomCredentialOrSkip(liteflowConfig, "MultiTurnMemoryTest");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -31,7 +31,6 @@ public class SessionReuseTest extends BaseAgentLiveTest {
|
||||
@BeforeEach
|
||||
public void reset() {
|
||||
MemoryAgentCmp.reset();
|
||||
LiveTestSupport.ensureCompatibleCustomCredentialOrSkip(liteflowConfig, "SessionReuseTest");
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -2,21 +2,17 @@ package com.yomahub.liteflow.test.agent.feature.cmp;
|
||||
|
||||
import com.yomahub.liteflow.agent.model.ModelSpec;
|
||||
import com.yomahub.liteflow.agent.openai.OpenAICompatible;
|
||||
import com.yomahub.liteflow.property.agent.PlatformCredential;
|
||||
import com.yomahub.liteflow.test.agent.cmp.AbstractCompatibleCustomAgentCmp;
|
||||
import com.yomahub.liteflow.test.agent.support.LiveTestEnv;
|
||||
import com.yomahub.liteflow.test.agent.support.FakeEchoModel;
|
||||
import com.yomahub.liteflow.test.agent.support.LiveTestSupport;
|
||||
import io.agentscope.core.model.GenerateOptions;
|
||||
import io.agentscope.core.model.Model;
|
||||
import io.agentscope.core.model.OpenAIChatModel;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* 演示 guide §4.6 buildModel() 逃生舱:完全自行构造 agentscope Model。
|
||||
* 这里仍然使用 compatible-custom 的 baseUrl/apiKey,但绕过 ModelSpec 而直接
|
||||
* 调用 {@link OpenAIChatModel#builder()}。
|
||||
* 这里返回本地 fake model,避免框架语义测试依赖外部模型服务。
|
||||
*/
|
||||
@Component("buildModelEscapeAgent")
|
||||
public class BuildModelEscapeAgentCmp extends AbstractCompatibleCustomAgentCmp {
|
||||
@@ -36,15 +32,6 @@ public class BuildModelEscapeAgentCmp extends AbstractCompatibleCustomAgentCmp {
|
||||
@Override
|
||||
protected Model buildModel() {
|
||||
BUILD_MODEL_COUNT.incrementAndGet();
|
||||
PlatformCredential cred = agentConfig().getOpenaiCompatible().get(LiveTestSupport.COMPATIBLE_CONFIG_KEY);
|
||||
String modelName = LiveTestEnv.resolveOrDefault(LiveTestEnv.COMPATIBLE_MODEL, "gpt-4o-mini");
|
||||
OpenAIChatModel.Builder builder = OpenAIChatModel.builder()
|
||||
.apiKey(cred.getApiKey())
|
||||
.modelName(modelName)
|
||||
.generateOptions(GenerateOptions.builder().temperature(0.1).maxTokens(64).build());
|
||||
if (cred.getBaseUrl() != null && !cred.getBaseUrl().isBlank()) {
|
||||
builder.baseUrl(cred.getBaseUrl());
|
||||
}
|
||||
return builder.build();
|
||||
return new FakeEchoModel("fake-build-model-escape");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,8 +2,12 @@ package com.yomahub.liteflow.test.agent.feature.cmp;
|
||||
|
||||
import com.yomahub.liteflow.test.agent.cmp.AbstractCompatibleCustomAgentCmp;
|
||||
import com.yomahub.liteflow.test.agent.feature.probe.AgentProbe;
|
||||
import com.yomahub.liteflow.test.agent.support.FakeEchoModel;
|
||||
import io.agentscope.core.hook.Hook;
|
||||
import io.agentscope.core.hook.HookEvent;
|
||||
import io.agentscope.core.model.Model;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
@@ -17,6 +21,13 @@ public class MemoryAgentCmp extends AbstractCompatibleCustomAgentCmp {
|
||||
|
||||
public static final String FIXED_CONVERSATION_ID = "memory-test-conversation";
|
||||
public static final AtomicReference<AgentProbe> PROBE = new AtomicReference<>();
|
||||
private static final Hook PROBE_FORWARDING_HOOK = new Hook() {
|
||||
@Override
|
||||
public <T extends HookEvent> Mono<T> onEvent(T event) {
|
||||
AgentProbe probe = PROBE.get();
|
||||
return probe == null ? Mono.just(event) : probe.hook().onEvent(event);
|
||||
}
|
||||
};
|
||||
|
||||
public static void reset() {
|
||||
PROBE.set(new AgentProbe());
|
||||
@@ -27,9 +38,13 @@ public class MemoryAgentCmp extends AbstractCompatibleCustomAgentCmp {
|
||||
return FIXED_CONVERSATION_ID;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Model buildModel() {
|
||||
return new FakeEchoModel("fake-memory-model");
|
||||
}
|
||||
|
||||
@Override
|
||||
protected List<Hook> hooks() {
|
||||
AgentProbe probe = PROBE.get();
|
||||
return probe == null ? List.of() : List.of(probe.hook());
|
||||
return List.of(PROBE_FORWARDING_HOOK);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.yomahub.liteflow.test.agent.support;
|
||||
|
||||
import io.agentscope.core.message.Msg;
|
||||
import io.agentscope.core.message.TextBlock;
|
||||
import io.agentscope.core.model.ChatResponse;
|
||||
import io.agentscope.core.model.GenerateOptions;
|
||||
import io.agentscope.core.model.Model;
|
||||
import io.agentscope.core.model.ToolSchema;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Deterministic local model for framework-level agent tests.
|
||||
*/
|
||||
public class FakeEchoModel implements Model {
|
||||
|
||||
private final String modelName;
|
||||
|
||||
public FakeEchoModel(String modelName) {
|
||||
this.modelName = modelName;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<ChatResponse> stream(List<Msg> messages, List<ToolSchema> toolSchemas, GenerateOptions options) {
|
||||
String prompt = messages == null || messages.isEmpty()
|
||||
? ""
|
||||
: messages.get(messages.size() - 1).getTextContent();
|
||||
return Flux.just(ChatResponse.builder()
|
||||
.content(List.of(TextBlock.builder()
|
||||
.text("fake reply: " + (prompt == null ? "" : prompt))
|
||||
.build()))
|
||||
.finishReason("stop")
|
||||
.build());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getModelName() {
|
||||
return modelName;
|
||||
}
|
||||
}
|
||||
@@ -70,6 +70,17 @@ public class ManagedShellCommandToolUnitTest {
|
||||
tool.executeCommand("pwd").trim());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUnsupportedShellSyntaxIsRejectedBeforeExecution() {
|
||||
AgentConfig cfg = newConfig(ShellMode.WHITELIST);
|
||||
cfg.getShell().setWhitelist(List.of("python3", "echo"));
|
||||
ManagedShellCommandTool tool = new ManagedShellCommandTool(workspace, cfg);
|
||||
|
||||
Assertions.assertTrue(tool.executeCommand("python3 - <<'PY'").contains("unsupported shell syntax"));
|
||||
Assertions.assertTrue(tool.executeCommand("echo hi | wc -c").contains("unsupported shell syntax"));
|
||||
Assertions.assertTrue(tool.executeCommand("echo hi && echo bye").contains("unsupported shell syntax"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testTimeoutKillsLongRunningCommand() {
|
||||
AgentConfig cfg = newConfig(ShellMode.WHITELIST);
|
||||
@@ -81,6 +92,18 @@ public class ManagedShellCommandToolUnitTest {
|
||||
Assertions.assertTrue(out.contains("timeout"), "should timeout, got: " + out);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCommandWaitingForStdinReturnsWithinShellTimeout() {
|
||||
AgentConfig cfg = newConfig(ShellMode.WHITELIST);
|
||||
cfg.getShell().setWhitelist(List.of("cat"));
|
||||
cfg.getShell().setTimeout(Duration.ofMillis(200));
|
||||
ManagedShellCommandTool tool = new ManagedShellCommandTool(workspace, cfg);
|
||||
|
||||
String out = Assertions.assertTimeoutPreemptively(Duration.ofSeconds(2),
|
||||
() -> tool.executeCommand("cat"));
|
||||
Assertions.assertTrue(out.isBlank() || out.contains("timeout"), "should not hang, got: " + out);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMaxOutputBytesTruncatesOutput() {
|
||||
// 用 head -c 4 控制输出长度,并把 max-output-bytes 设为 2 来观察截断。
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
package com.yomahub.liteflow.test.agent.unit;
|
||||
|
||||
import ch.qos.logback.classic.Level;
|
||||
import ch.qos.logback.classic.Logger;
|
||||
import ch.qos.logback.classic.spi.ILoggingEvent;
|
||||
import ch.qos.logback.core.read.ListAppender;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yomahub.liteflow.agent.hook.ReActLoggingHook;
|
||||
import io.agentscope.core.agent.Agent;
|
||||
import io.agentscope.core.agent.Event;
|
||||
import io.agentscope.core.agent.StreamOptions;
|
||||
import io.agentscope.core.hook.PostActingEvent;
|
||||
import io.agentscope.core.message.Msg;
|
||||
import io.agentscope.core.message.TextBlock;
|
||||
import io.agentscope.core.message.ToolResultBlock;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class ReActLoggingHookTest {
|
||||
|
||||
@Test
|
||||
void postActingResultLogUsesSessionIdOnEveryLine() {
|
||||
Logger logger = (Logger) LoggerFactory.getLogger(ReActLoggingHook.class);
|
||||
ListAppender<ILoggingEvent> appender = new ListAppender<>();
|
||||
appender.start();
|
||||
logger.addAppender(appender);
|
||||
Level originalLevel = logger.getLevel();
|
||||
logger.setLevel(Level.INFO);
|
||||
|
||||
try {
|
||||
ReActLoggingHook hook = new ReActLoggingHook("session-1:agent");
|
||||
ToolResultBlock result = new ToolResultBlock(
|
||||
"tool-call-1",
|
||||
"read_file",
|
||||
List.of(TextBlock.builder().text("file content").build()));
|
||||
|
||||
hook.onEvent(new PostActingEvent(new TestAgent(), null, null, result)).block();
|
||||
|
||||
assertThat(appender.list)
|
||||
.extracting(ILoggingEvent::getFormattedMessage)
|
||||
.contains(
|
||||
"[agent:act][session-1:agent] <<< read_file 结果:",
|
||||
"[agent:act][session-1:agent] file content")
|
||||
.noneMatch(message -> message.contains("[agent:act][...]"));
|
||||
} finally {
|
||||
logger.detachAppender(appender);
|
||||
logger.setLevel(originalLevel);
|
||||
}
|
||||
}
|
||||
|
||||
private static class TestAgent implements Agent {
|
||||
|
||||
@Override
|
||||
public String getAgentId() {
|
||||
return "test-agent";
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getName() {
|
||||
return "TestAgent";
|
||||
}
|
||||
|
||||
@Override
|
||||
public void interrupt() {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void interrupt(Msg msg) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Msg> call(List<Msg> msgs) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Msg> call(List<Msg> msgs, Class<?> structuredModel) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Msg> call(List<Msg> msgs, JsonNode schema) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<Event> stream(List<Msg> msgs, StreamOptions options) {
|
||||
return Flux.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<Event> stream(List<Msg> msgs, StreamOptions options, Class<?> structuredModel) {
|
||||
return Flux.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Flux<Event> stream(List<Msg> msgs, StreamOptions options, JsonNode schema) {
|
||||
return Flux.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> observe(Msg msg) {
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> observe(List<Msg> msgs) {
|
||||
return Mono.empty();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user