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:
everywhere.z
2026-05-17 23:23:45 +08:00
parent 5c91e56bec
commit 8ea01d2e88
14 changed files with 377 additions and 35 deletions

View File

@@ -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>

View File

@@ -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())

View File

@@ -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());
}

View File

@@ -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<>();

View File

@@ -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);
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -31,7 +31,6 @@ public class SessionReuseTest extends BaseAgentLiveTest {
@BeforeEach
public void reset() {
MemoryAgentCmp.reset();
LiveTestSupport.ensureCompatibleCustomCredentialOrSkip(liteflowConfig, "SessionReuseTest");
}
@Test

View File

@@ -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");
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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 来观察截断。

View File

@@ -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();
}
}
}