feat: 打通智能体图片媒体运行链路
- 增加稳定媒体引用解析与模型图片能力校验 - 修复图片 Data URI 与模型完整文本读取 - 补充媒体解析和消息兼容测试
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
package com.easyagents.agent.runtime;
|
package com.easyagents.agent.runtime;
|
||||||
|
|
||||||
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever;
|
import com.easyagents.agent.runtime.knowledge.AgentKnowledgeRetriever;
|
||||||
|
import com.easyagents.agent.runtime.media.AgentMediaResolver;
|
||||||
import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder;
|
import com.easyagents.agent.runtime.persistence.conversation.AgentConversationRecorder;
|
||||||
import com.easyagents.agent.runtime.persistence.conversation.noop.NoopAgentConversationRecorder;
|
import com.easyagents.agent.runtime.persistence.conversation.noop.NoopAgentConversationRecorder;
|
||||||
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
import com.easyagents.agent.runtime.persistence.session.AgentSessionStore;
|
||||||
@@ -55,6 +56,11 @@ public class AgentInitRequest {
|
|||||||
*/
|
*/
|
||||||
private Map<String, Object> metadata = new LinkedHashMap<>();
|
private Map<String, Object> metadata = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 媒体引用解析器,仅在模型调用前解析稳定引用。
|
||||||
|
*/
|
||||||
|
private AgentMediaResolver mediaResolver;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取会话ID。
|
* 获取会话ID。
|
||||||
*
|
*
|
||||||
@@ -200,4 +206,22 @@ public class AgentInitRequest {
|
|||||||
public void setMetadata(Map<String, Object> metadata) {
|
public void setMetadata(Map<String, Object> metadata) {
|
||||||
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
|
this.metadata = metadata == null ? new LinkedHashMap<>() : metadata;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取媒体引用解析器。
|
||||||
|
*
|
||||||
|
* @return 媒体引用解析器
|
||||||
|
*/
|
||||||
|
public AgentMediaResolver getMediaResolver() {
|
||||||
|
return mediaResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置媒体引用解析器。
|
||||||
|
*
|
||||||
|
* @param mediaResolver 媒体引用解析器
|
||||||
|
*/
|
||||||
|
public void setMediaResolver(AgentMediaResolver mediaResolver) {
|
||||||
|
this.mediaResolver = mediaResolver;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import io.agentscope.core.message.*;
|
|||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -15,6 +17,9 @@ import java.util.Map;
|
|||||||
*/
|
*/
|
||||||
public class AgentScopeMessageAdapter {
|
public class AgentScopeMessageAdapter {
|
||||||
|
|
||||||
|
/** AgentScope URLSource 中用于持久化业务媒体引用的私有协议。 */
|
||||||
|
public static final String MEDIA_REFERENCE_SCHEME = "easyagents-media://";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将运行时消息转换为 AgentScope 消息。
|
* 将运行时消息转换为 AgentScope 消息。
|
||||||
*
|
*
|
||||||
@@ -276,6 +281,11 @@ public class AgentScopeMessageAdapter {
|
|||||||
.data(block.getData())
|
.data(block.getData())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
if (block.getReference() != null && !block.getReference().isBlank()) {
|
||||||
|
String encodedReference = Base64.getUrlEncoder().withoutPadding()
|
||||||
|
.encodeToString(block.getReference().getBytes(StandardCharsets.UTF_8));
|
||||||
|
return URLSource.builder().url(MEDIA_REFERENCE_SCHEME + encodedReference).build();
|
||||||
|
}
|
||||||
return URLSource.builder().url(block.getUrl()).build();
|
return URLSource.builder().url(block.getUrl()).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -286,7 +296,30 @@ public class AgentScopeMessageAdapter {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (source instanceof URLSource urlSource) {
|
if (source instanceof URLSource urlSource) {
|
||||||
block.setUrl(urlSource.getUrl());
|
String url = urlSource.getUrl();
|
||||||
|
if (url != null && url.startsWith(MEDIA_REFERENCE_SCHEME)) {
|
||||||
|
block.setReference(decodeReference(url));
|
||||||
|
} else {
|
||||||
|
block.setUrl(url);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 AgentScope 私有 URLSource 解码业务媒体引用。
|
||||||
|
*
|
||||||
|
* @param url 私有协议 URL
|
||||||
|
* @return 原始业务媒体引用
|
||||||
|
*/
|
||||||
|
public static String decodeReference(String url) {
|
||||||
|
if (url == null || !url.startsWith(MEDIA_REFERENCE_SCHEME)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
String encoded = url.substring(MEDIA_REFERENCE_SCHEME.length());
|
||||||
|
try {
|
||||||
|
return new String(Base64.getUrlDecoder().decode(encoded), StandardCharsets.UTF_8);
|
||||||
|
} catch (IllegalArgumentException error) {
|
||||||
|
throw new IllegalArgumentException("Invalid agent media reference.", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.easyagents.agent.runtime.agentscope;
|
|||||||
import com.easyagents.agent.runtime.*;
|
import com.easyagents.agent.runtime.*;
|
||||||
import com.easyagents.agent.runtime.event.*;
|
import com.easyagents.agent.runtime.event.*;
|
||||||
import com.easyagents.agent.runtime.event.interceptor.AutoContextInterceptor;
|
import com.easyagents.agent.runtime.event.interceptor.AutoContextInterceptor;
|
||||||
|
import com.easyagents.agent.runtime.event.interceptor.MediaReferenceInterceptor;
|
||||||
import com.easyagents.agent.runtime.event.interceptor.ToolHitlInterceptor;
|
import com.easyagents.agent.runtime.event.interceptor.ToolHitlInterceptor;
|
||||||
import com.easyagents.agent.runtime.event.observer.AgentRuntimeErrorObserver;
|
import com.easyagents.agent.runtime.event.observer.AgentRuntimeErrorObserver;
|
||||||
import com.easyagents.agent.runtime.event.observer.ReasoningLifecycleObserver;
|
import com.easyagents.agent.runtime.event.observer.ReasoningLifecycleObserver;
|
||||||
@@ -303,6 +304,12 @@ public class AgentScopeReActRuntime implements AgentRuntime {
|
|||||||
if (userMessage.getContentBlocks() == null || userMessage.getContentBlocks().isEmpty()) {
|
if (userMessage.getContentBlocks() == null || userMessage.getContentBlocks().isEmpty()) {
|
||||||
throw new AgentRuntimeException("Agent user message content is required.");
|
throw new AgentRuntimeException("Agent user message content is required.");
|
||||||
}
|
}
|
||||||
|
boolean containsImage = userMessage.getContentBlocks().stream()
|
||||||
|
.anyMatch(block -> block instanceof AgentMediaBlock mediaBlock
|
||||||
|
&& "image".equalsIgnoreCase(mediaBlock.getMediaKind()));
|
||||||
|
if (containsImage && !initRequest.getAgentDefinition().getModelSpec().isSupportImage()) {
|
||||||
|
throw new AgentRuntimeException("The configured model does not support image input.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1087,6 +1094,7 @@ public class AgentScopeReActRuntime implements AgentRuntime {
|
|||||||
if (memory instanceof AutoContextMemory) {
|
if (memory instanceof AutoContextMemory) {
|
||||||
interceptors.add(new AutoContextInterceptor(eventBridge, memoryResult.getAutoContextConfig()));
|
interceptors.add(new AutoContextInterceptor(eventBridge, memoryResult.getAutoContextConfig()));
|
||||||
}
|
}
|
||||||
|
interceptors.add(new MediaReferenceInterceptor(initRequest.getMediaResolver()));
|
||||||
List<AgentToolSpec> runtimeToolSpecs = mergeToolSpecs(definition.getToolSpecs(), toolkitBuildResult.mcpToolSpecs(),
|
List<AgentToolSpec> runtimeToolSpecs = mergeToolSpecs(definition.getToolSpecs(), toolkitBuildResult.mcpToolSpecs(),
|
||||||
toolkitBuildResult.operateToolSpecs());
|
toolkitBuildResult.operateToolSpecs());
|
||||||
interceptors.add(new ToolHitlInterceptor(eventBridge, approvalCoordinator,
|
interceptors.add(new ToolHitlInterceptor(eventBridge, approvalCoordinator,
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package com.easyagents.agent.runtime.event.interceptor;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.AgentRuntimeException;
|
||||||
|
import com.easyagents.agent.runtime.agentscope.AgentScopeMessageAdapter;
|
||||||
|
import com.easyagents.agent.runtime.event.AgentRuntimeInterceptor;
|
||||||
|
import com.easyagents.agent.runtime.media.AgentMediaResolver;
|
||||||
|
import com.easyagents.agent.runtime.media.AgentMediaResource;
|
||||||
|
import io.agentscope.core.hook.HookEvent;
|
||||||
|
import io.agentscope.core.hook.PreReasoningEvent;
|
||||||
|
import io.agentscope.core.message.*;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Base64;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在推理前将持久化的业务媒体引用解析为模型可用的 Base64 内容。
|
||||||
|
*
|
||||||
|
* <p>输入消息会被深拷贝,AgentScope memory/session 中仍保留小体积稳定引用。</p>
|
||||||
|
*/
|
||||||
|
public class MediaReferenceInterceptor implements AgentRuntimeInterceptor {
|
||||||
|
|
||||||
|
private final AgentMediaResolver mediaResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建媒体引用干预器。
|
||||||
|
*
|
||||||
|
* @param mediaResolver 媒体引用解析器
|
||||||
|
*/
|
||||||
|
public MediaReferenceInterceptor(AgentMediaResolver mediaResolver) {
|
||||||
|
this.mediaResolver = mediaResolver;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析推理输入中的内部媒体引用。
|
||||||
|
*
|
||||||
|
* @param event AgentScope Hook 事件
|
||||||
|
* @param <T> Hook 事件类型
|
||||||
|
* @return 处理后的事件
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public <T extends HookEvent> Mono<T> intercept(T event) {
|
||||||
|
if (event instanceof PreReasoningEvent preReasoningEvent) {
|
||||||
|
preReasoningEvent.setInputMessages(resolveMessages(preReasoningEvent.getInputMessages()));
|
||||||
|
}
|
||||||
|
return Mono.just(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在 AutoContext 完成消息重写后执行媒体解析。
|
||||||
|
*
|
||||||
|
* @return 执行优先级
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public int priority() {
|
||||||
|
return 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Msg> resolveMessages(List<Msg> messages) {
|
||||||
|
if (messages == null || messages.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<Msg> resolved = new ArrayList<>(messages.size());
|
||||||
|
for (Msg message : messages) {
|
||||||
|
List<ContentBlock> content = resolveBlocks(message.getContent());
|
||||||
|
resolved.add(Msg.builder()
|
||||||
|
.id(message.getId())
|
||||||
|
.name(message.getName())
|
||||||
|
.role(message.getRole())
|
||||||
|
.content(content)
|
||||||
|
.metadata(message.getMetadata())
|
||||||
|
.timestamp(message.getTimestamp())
|
||||||
|
.build());
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<ContentBlock> resolveBlocks(List<ContentBlock> blocks) {
|
||||||
|
if (blocks == null || blocks.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
List<ContentBlock> resolved = new ArrayList<>(blocks.size());
|
||||||
|
for (ContentBlock block : blocks) {
|
||||||
|
if (block instanceof ImageBlock imageBlock) {
|
||||||
|
resolved.add(resolveImage(imageBlock));
|
||||||
|
} else if (block instanceof AudioBlock audioBlock) {
|
||||||
|
resolved.add(resolveAudio(audioBlock));
|
||||||
|
} else if (block instanceof VideoBlock videoBlock) {
|
||||||
|
resolved.add(resolveVideo(videoBlock));
|
||||||
|
} else {
|
||||||
|
resolved.add(block);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
private ImageBlock resolveImage(ImageBlock block) {
|
||||||
|
AgentMediaResource resource = resolve(block.getSource());
|
||||||
|
if (resource == null) {
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
return ImageBlock.builder()
|
||||||
|
.source(base64Source(resource))
|
||||||
|
.minPixels(block.getMinPixels())
|
||||||
|
.maxPixels(block.getMaxPixels())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AudioBlock resolveAudio(AudioBlock block) {
|
||||||
|
AgentMediaResource resource = resolve(block.getSource());
|
||||||
|
return resource == null ? block : AudioBlock.builder().source(base64Source(resource)).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private VideoBlock resolveVideo(VideoBlock block) {
|
||||||
|
AgentMediaResource resource = resolve(block.getSource());
|
||||||
|
if (resource == null) {
|
||||||
|
return block;
|
||||||
|
}
|
||||||
|
return VideoBlock.builder()
|
||||||
|
.source(base64Source(resource))
|
||||||
|
.fps(block.getFps())
|
||||||
|
.maxFrames(block.getMaxFrames())
|
||||||
|
.minPixels(block.getMinPixels())
|
||||||
|
.maxPixels(block.getMaxPixels())
|
||||||
|
.totalPixels(block.getTotalPixels())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private AgentMediaResource resolve(Source source) {
|
||||||
|
if (!(source instanceof URLSource urlSource)
|
||||||
|
|| urlSource.getUrl() == null
|
||||||
|
|| !urlSource.getUrl().startsWith(AgentScopeMessageAdapter.MEDIA_REFERENCE_SCHEME)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (mediaResolver == null) {
|
||||||
|
throw new AgentRuntimeException("Agent media resolver is required for media references.");
|
||||||
|
}
|
||||||
|
String reference;
|
||||||
|
try {
|
||||||
|
reference = AgentScopeMessageAdapter.decodeReference(urlSource.getUrl());
|
||||||
|
} catch (IllegalArgumentException error) {
|
||||||
|
throw new AgentRuntimeException("Agent media reference is invalid.", error);
|
||||||
|
}
|
||||||
|
AgentMediaResource resource = mediaResolver.resolve(reference);
|
||||||
|
if (resource == null || resource.bytes().length == 0 || resource.mimeType() == null
|
||||||
|
|| resource.mimeType().isBlank()) {
|
||||||
|
throw new AgentRuntimeException("Agent media resource is empty.");
|
||||||
|
}
|
||||||
|
return resource;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Base64Source base64Source(AgentMediaResource resource) {
|
||||||
|
return Base64Source.builder()
|
||||||
|
.mediaType(resource.mimeType())
|
||||||
|
.data(Base64.getEncoder().encodeToString(resource.bytes()))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package com.easyagents.agent.runtime.media;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 在模型调用边界解析业务侧稳定媒体引用。
|
||||||
|
*/
|
||||||
|
@FunctionalInterface
|
||||||
|
public interface AgentMediaResolver {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析媒体引用。
|
||||||
|
*
|
||||||
|
* @param reference 业务侧稳定媒体引用
|
||||||
|
* @return 媒体资源
|
||||||
|
* @throws RuntimeException 引用无效、越权或资源读取失败时抛出
|
||||||
|
*/
|
||||||
|
AgentMediaResource resolve(String reference);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.easyagents.agent.runtime.media;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型调用前解析得到的媒体资源。
|
||||||
|
*
|
||||||
|
* @param mimeType MIME 类型
|
||||||
|
* @param bytes 媒体字节
|
||||||
|
*/
|
||||||
|
public record AgentMediaResource(String mimeType, byte[] bytes) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建不可变媒体资源。
|
||||||
|
*/
|
||||||
|
public AgentMediaResource {
|
||||||
|
bytes = bytes == null ? new byte[0] : Arrays.copyOf(bytes, bytes.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回媒体字节副本。
|
||||||
|
*
|
||||||
|
* @return 媒体字节副本
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public byte[] bytes() {
|
||||||
|
return Arrays.copyOf(bytes, bytes.length);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import java.util.Map;
|
|||||||
public class AgentMediaBlock extends AgentContentBlock {
|
public class AgentMediaBlock extends AgentContentBlock {
|
||||||
|
|
||||||
private String mimeType;
|
private String mimeType;
|
||||||
|
private String reference;
|
||||||
private String url;
|
private String url;
|
||||||
private String data;
|
private String data;
|
||||||
private Integer minPixels;
|
private Integer minPixels;
|
||||||
@@ -64,6 +65,24 @@ public class AgentMediaBlock extends AgentContentBlock {
|
|||||||
this.mimeType = mimeType;
|
this.mimeType = mimeType;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取由业务侧解析的稳定媒体引用。
|
||||||
|
*
|
||||||
|
* @return 媒体引用
|
||||||
|
*/
|
||||||
|
public String getReference() {
|
||||||
|
return reference;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置由业务侧解析的稳定媒体引用。
|
||||||
|
*
|
||||||
|
* @param reference 媒体引用
|
||||||
|
*/
|
||||||
|
public void setReference(String reference) {
|
||||||
|
this.reference = reference;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取 URL。
|
* 获取 URL。
|
||||||
*
|
*
|
||||||
@@ -209,6 +228,7 @@ public class AgentMediaBlock extends AgentContentBlock {
|
|||||||
Map<String, Object> metadata = new LinkedHashMap<>(getMetadata());
|
Map<String, Object> metadata = new LinkedHashMap<>(getMetadata());
|
||||||
metadata.put("mediaKind", mediaKind);
|
metadata.put("mediaKind", mediaKind);
|
||||||
metadata.put("mimeType", mimeType);
|
metadata.put("mimeType", mimeType);
|
||||||
|
metadata.put("reference", reference);
|
||||||
metadata.put("url", url);
|
metadata.put("url", url);
|
||||||
metadata.put("data", data);
|
metadata.put("data", data);
|
||||||
metadata.put("minPixels", minPixels);
|
metadata.put("minPixels", minPixels);
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import com.easyagents.agent.runtime.memory.AgentMemoryPolicy;
|
|||||||
import com.easyagents.agent.runtime.memory.AgentMemorySnapshot;
|
import com.easyagents.agent.runtime.memory.AgentMemorySnapshot;
|
||||||
import com.easyagents.agent.runtime.message.AgentMessage;
|
import com.easyagents.agent.runtime.message.AgentMessage;
|
||||||
import com.easyagents.agent.runtime.message.AgentMessageRole;
|
import com.easyagents.agent.runtime.message.AgentMessageRole;
|
||||||
|
import com.easyagents.agent.runtime.message.AgentMediaBlock;
|
||||||
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
import com.easyagents.agent.runtime.model.AgentModelSpec;
|
||||||
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
|
import com.easyagents.agent.runtime.persistence.session.memory.InMemoryAgentSessionStore;
|
||||||
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
|
import com.easyagents.agent.runtime.skill.AgentSkillBoxSpec;
|
||||||
@@ -78,6 +79,20 @@ public class AgentScopeStatefulRuntimeTest {
|
|||||||
runtime.stream(message);
|
runtime.stream(message);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test(expected = AgentRuntimeException.class)
|
||||||
|
public void shouldRejectImageWhenModelCapabilityIsDisabled() {
|
||||||
|
AgentScopeReActRuntime runtime = fakeRuntime();
|
||||||
|
AgentInitRequest request = initRequest();
|
||||||
|
runtime.init(request);
|
||||||
|
AgentMessage message = new AgentMessage();
|
||||||
|
message.setRole(AgentMessageRole.USER);
|
||||||
|
AgentMediaBlock image = new AgentMediaBlock("image");
|
||||||
|
image.setReference("draft:upload-1");
|
||||||
|
message.setContentBlocks(List.of(image));
|
||||||
|
|
||||||
|
runtime.stream(message);
|
||||||
|
}
|
||||||
|
|
||||||
@Test(expected = AgentRuntimeException.class)
|
@Test(expected = AgentRuntimeException.class)
|
||||||
public void shouldRejectDuplicateInit() {
|
public void shouldRejectDuplicateInit() {
|
||||||
AgentScopeReActRuntime runtime = fakeRuntime();
|
AgentScopeReActRuntime runtime = fakeRuntime();
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
package com.easyagents.agent.runtime.event.interceptor;
|
||||||
|
|
||||||
|
import com.easyagents.agent.runtime.agentscope.AgentScopeMessageAdapter;
|
||||||
|
import com.easyagents.agent.runtime.media.AgentMediaResource;
|
||||||
|
import com.easyagents.agent.runtime.message.AgentMediaBlock;
|
||||||
|
import io.agentscope.core.ReActAgent;
|
||||||
|
import io.agentscope.core.hook.PreReasoningEvent;
|
||||||
|
import io.agentscope.core.message.Base64Source;
|
||||||
|
import io.agentscope.core.message.ImageBlock;
|
||||||
|
import io.agentscope.core.message.Msg;
|
||||||
|
import io.agentscope.core.message.MsgRole;
|
||||||
|
import io.agentscope.core.message.URLSource;
|
||||||
|
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 io.agentscope.core.tool.Toolkit;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 测试模型调用边界的媒体引用解析。
|
||||||
|
*/
|
||||||
|
public class MediaReferenceInterceptorTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证私有引用仅在模型调用边界转换为 Base64,原始记忆消息保持稳定引用。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldResolveReferenceToBase64WithoutMutatingOriginalMessage() {
|
||||||
|
AgentScopeMessageAdapter adapter = new AgentScopeMessageAdapter();
|
||||||
|
AgentMediaBlock mediaBlock = new AgentMediaBlock("image");
|
||||||
|
mediaBlock.setReference("draft:upload-1");
|
||||||
|
mediaBlock.setMimeType("image/png");
|
||||||
|
Msg original = Msg.builder()
|
||||||
|
.id("message-1")
|
||||||
|
.name("user")
|
||||||
|
.role(MsgRole.USER)
|
||||||
|
.content(adapter.toContentBlock(mediaBlock))
|
||||||
|
.build();
|
||||||
|
AtomicReference<String> resolvedReference = new AtomicReference<>();
|
||||||
|
MediaReferenceInterceptor interceptor = new MediaReferenceInterceptor(reference -> {
|
||||||
|
resolvedReference.set(reference);
|
||||||
|
return new AgentMediaResource("image/png", "png-data".getBytes(StandardCharsets.UTF_8));
|
||||||
|
});
|
||||||
|
ReActAgent agent = ReActAgent.builder()
|
||||||
|
.name("media-test-agent")
|
||||||
|
.sysPrompt("system")
|
||||||
|
.model(new EmptyModel())
|
||||||
|
.toolkit(new Toolkit())
|
||||||
|
.build();
|
||||||
|
PreReasoningEvent event = new PreReasoningEvent(agent, "reasoning-1", null, List.of(original));
|
||||||
|
|
||||||
|
interceptor.intercept(event).block();
|
||||||
|
|
||||||
|
ImageBlock originalImage = original.getFirstContentBlock(ImageBlock.class);
|
||||||
|
ImageBlock resolvedImage = event.getInputMessages().get(0).getFirstContentBlock(ImageBlock.class);
|
||||||
|
Assert.assertTrue(originalImage.getSource() instanceof URLSource);
|
||||||
|
Assert.assertTrue(((URLSource) originalImage.getSource()).getUrl()
|
||||||
|
.startsWith(AgentScopeMessageAdapter.MEDIA_REFERENCE_SCHEME));
|
||||||
|
Assert.assertTrue(resolvedImage.getSource() instanceof Base64Source);
|
||||||
|
Assert.assertEquals("draft:upload-1", resolvedReference.get());
|
||||||
|
Assert.assertEquals("cG5nLWRhdGE=", ((Base64Source) resolvedImage.getSource()).getData());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证消息适配器序列化后仍能恢复不透明媒体引用。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldRoundTripStableReferenceThroughMessageAdapter() {
|
||||||
|
AgentScopeMessageAdapter adapter = new AgentScopeMessageAdapter();
|
||||||
|
AgentMediaBlock mediaBlock = new AgentMediaBlock("image");
|
||||||
|
mediaBlock.setReference("formal:message-1:0:image/png");
|
||||||
|
|
||||||
|
AgentMediaBlock restored = (AgentMediaBlock) adapter.toAgentBlock(adapter.toContentBlock(mediaBlock));
|
||||||
|
|
||||||
|
Assert.assertEquals("formal:message-1:0:image/png", restored.getReference());
|
||||||
|
Assert.assertNull(restored.getData());
|
||||||
|
Assert.assertNull(restored.getUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 仅用于构建真实 AgentScope 事件的空模型。
|
||||||
|
*/
|
||||||
|
private static final class EmptyModel implements Model {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回空响应流,当前测试不会实际调用模型。
|
||||||
|
*
|
||||||
|
* @param messages 输入消息
|
||||||
|
* @param toolSchemas 工具定义
|
||||||
|
* @param options 生成参数
|
||||||
|
* @return 空响应流
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Flux<ChatResponse> stream(List<Msg> messages,
|
||||||
|
List<ToolSchema> toolSchemas,
|
||||||
|
GenerateOptions options) {
|
||||||
|
return Flux.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 返回测试模型名称。
|
||||||
|
*
|
||||||
|
* @return 测试模型名称
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public String getModelName() {
|
||||||
|
return "media-test-model";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -233,7 +233,7 @@ public class ReActAgent implements IAgent {
|
|||||||
AiMessageResponse response = chatModel.chat(memoryPrompt, chatOptions);
|
AiMessageResponse response = chatModel.chat(memoryPrompt, chatOptions);
|
||||||
notifyOnChatResponse(response);
|
notifyOnChatResponse(response);
|
||||||
|
|
||||||
String content = response.getMessage().getContent();
|
String content = response.getMessage().getTextContent();
|
||||||
AiMessage message = new AiMessage(content);
|
AiMessage message = new AiMessage(content);
|
||||||
|
|
||||||
// 请求用户输入
|
// 请求用户输入
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import com.easyagents.core.util.StringUtil;
|
|||||||
|
|
||||||
import java.util.*;
|
import java.util.*;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 表示模型生成的文本、推理内容和工具调用消息。
|
||||||
|
*/
|
||||||
public class AiMessage extends AbstractTextMessage<AiMessage> {
|
public class AiMessage extends AbstractTextMessage<AiMessage> {
|
||||||
|
|
||||||
private Integer index;
|
private Integer index;
|
||||||
@@ -192,8 +195,13 @@ public class AiMessage extends AbstractTextMessage<AiMessage> {
|
|||||||
this.localTotalTokens = localTotalTokens;
|
this.localTotalTokens = localTotalTokens;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前消息的完整回复文本。
|
||||||
|
*
|
||||||
|
* @return 已聚合的完整文本;未聚合时返回当前文本
|
||||||
|
*/
|
||||||
public String getFullContent() {
|
public String getFullContent() {
|
||||||
return fullContent;
|
return fullContent != null ? fullContent : content;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setFullContent(String fullContent) {
|
public void setFullContent(String fullContent) {
|
||||||
@@ -226,7 +234,7 @@ public class AiMessage extends AbstractTextMessage<AiMessage> {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public String getTextContent() {
|
public String getTextContent() {
|
||||||
return fullContent;
|
return getFullContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -283,8 +291,13 @@ public class AiMessage extends AbstractTextMessage<AiMessage> {
|
|||||||
this.toolCalls = toolCalls;
|
this.toolCalls = toolCalls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前消息的完整推理文本。
|
||||||
|
*
|
||||||
|
* @return 已聚合的完整推理文本;未聚合时返回当前推理文本
|
||||||
|
*/
|
||||||
public String getFullReasoningContent() {
|
public String getFullReasoningContent() {
|
||||||
return fullReasoningContent;
|
return fullReasoningContent != null ? fullReasoningContent : reasoningContent;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void setFullReasoningContent(String fullReasoningContent) {
|
public void setFullReasoningContent(String fullReasoningContent) {
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ public interface ChatModel {
|
|||||||
if (response != null && response.isError()) {
|
if (response != null && response.isError()) {
|
||||||
throw new ModelException(response.getErrorMessage());
|
throw new ModelException(response.getErrorMessage());
|
||||||
}
|
}
|
||||||
return response != null && response.getMessage() != null ? response.getMessage().getContent() : null;
|
return response != null && response.getMessage() != null ? response.getMessage().getTextContent() : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
default AiMessageResponse chat(Prompt prompt) {
|
default AiMessageResponse chat(Prompt prompt) {
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ public class ChatObservabilityInterceptor implements ChatInterceptor {
|
|||||||
private void enrichSpan(Span span, AiMessage msg) {
|
private void enrichSpan(Span span, AiMessage msg) {
|
||||||
if (msg != null) {
|
if (msg != null) {
|
||||||
span.setAttribute("llm.total_tokens", msg.getEffectiveTotalTokens());
|
span.setAttribute("llm.total_tokens", msg.getEffectiveTotalTokens());
|
||||||
String content = msg.getContent();
|
String content = msg.getTextContent();
|
||||||
if (content != null) {
|
if (content != null) {
|
||||||
span.setAttribute("llm.response",
|
span.setAttribute("llm.response",
|
||||||
content.substring(0, Math.min(content.length(), MAX_RESPONSE_LENGTH_FOR_SPAN)));
|
content.substring(0, Math.min(content.length(), MAX_RESPONSE_LENGTH_FOR_SPAN)));
|
||||||
|
|||||||
@@ -32,6 +32,9 @@ import java.util.List;
|
|||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 OpenAI-compatible 同步响应和流式增量消息。
|
||||||
|
*/
|
||||||
public class DefaultAiMessageParser implements AiMessageParser<JSONObject> {
|
public class DefaultAiMessageParser implements AiMessageParser<JSONObject> {
|
||||||
|
|
||||||
private JSONPath contentPath;
|
private JSONPath contentPath;
|
||||||
@@ -171,11 +174,16 @@ public class DefaultAiMessageParser implements AiMessageParser<JSONObject> {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (this.contentPath != null) {
|
if (this.contentPath != null) {
|
||||||
aiMessage.setContent((String) this.contentPath.eval(rootJson));
|
String content = (String) this.contentPath.eval(rootJson);
|
||||||
|
// 非流式响应已经是完整结果,同时填充当前内容和完整内容。
|
||||||
|
aiMessage.setContent(content);
|
||||||
|
aiMessage.setFullContent(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.reasoningContentPath != null) {
|
if (this.reasoningContentPath != null) {
|
||||||
aiMessage.setReasoningContent((String) this.reasoningContentPath.eval(rootJson));
|
String reasoningContent = (String) this.reasoningContentPath.eval(rootJson);
|
||||||
|
aiMessage.setReasoningContent(reasoningContent);
|
||||||
|
aiMessage.setFullReasoningContent(reasoningContent);
|
||||||
}
|
}
|
||||||
if (this.toolCallsJsonPath != null) {
|
if (this.toolCallsJsonPath != null) {
|
||||||
toolCallsJsonArray = (JSONArray) this.toolCallsJsonPath.eval(rootJson);
|
toolCallsJsonArray = (JSONArray) this.toolCallsJsonPath.eval(rootJson);
|
||||||
|
|||||||
@@ -50,7 +50,7 @@ public class ImageUtil {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 将图片 URL 转换为 Data URI 格式的字符串(例如:image/jpeg;base64,...)
|
* 将图片 URL 转换为 Data URI 格式的字符串(例如:data:image/jpeg;base64,...)
|
||||||
*
|
*
|
||||||
* @throws IllegalArgumentException 如果 URL 无效或无法获取内容
|
* @throws IllegalArgumentException 如果 URL 无效或无法获取内容
|
||||||
*/
|
*/
|
||||||
@@ -108,6 +108,6 @@ public class ImageUtil {
|
|||||||
|
|
||||||
public static String imageBytesToDataUri(byte[] data, String mimeType) {
|
public static String imageBytesToDataUri(byte[] data, String mimeType) {
|
||||||
String base64 = Base64.getEncoder().encodeToString(data);
|
String base64 = Base64.getEncoder().encodeToString(data);
|
||||||
return mimeType + ";base64," + base64;
|
return "data:" + mimeType + ";base64," + base64;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package com.easyagents.core.message;
|
||||||
|
|
||||||
|
import com.easyagents.core.util.LocalTokenCounter;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 消息文本语义测试。
|
||||||
|
*/
|
||||||
|
public class AiMessageTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证仅设置当前内容时,完整文本接口仍能返回模型回复。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void currentContentShouldBeAvailableAsFullText() {
|
||||||
|
AiMessage message = new AiMessage();
|
||||||
|
message.setContent("2026");
|
||||||
|
|
||||||
|
Assert.assertEquals("2026", message.getContent());
|
||||||
|
Assert.assertEquals("2026", message.getFullContent());
|
||||||
|
Assert.assertEquals("2026", message.getTextContent());
|
||||||
|
Assert.assertTrue(LocalTokenCounter.countCompletionTokens(message)
|
||||||
|
> LocalTokenCounter.countCompletionTokens(new AiMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证流式消息保留增量内容,同时完整文本接口返回聚合内容。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void aggregatedContentShouldNotReplaceStreamingDelta() {
|
||||||
|
AiMessage message = new AiMessage();
|
||||||
|
message.setContent("26");
|
||||||
|
message.setFullContent("2026");
|
||||||
|
|
||||||
|
Assert.assertEquals("26", message.getContent());
|
||||||
|
Assert.assertEquals("2026", message.getFullContent());
|
||||||
|
Assert.assertEquals("2026", message.getTextContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证仅设置当前推理内容时,完整推理接口能够正确回退。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void reasoningContentShouldBeAvailableAsFullReasoningText() {
|
||||||
|
AiMessage message = new AiMessage();
|
||||||
|
message.setReasoningContent("识别图片中的数字");
|
||||||
|
|
||||||
|
Assert.assertEquals("识别图片中的数字", message.getFullReasoningContent());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package com.easyagents.core.test.parser;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.easyagents.core.message.AiMessage;
|
||||||
|
import com.easyagents.core.model.chat.ChatContext;
|
||||||
|
import com.easyagents.core.model.chat.ChatOptions;
|
||||||
|
import com.easyagents.core.parser.impl.DefaultAiMessageParser;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OpenAI-compatible AI 消息解析测试。
|
||||||
|
*/
|
||||||
|
public class DefaultAiMessageParserTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证非流式响应同时填充当前内容和完整内容。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void nonStreamingResponseShouldPopulateCompleteContent() {
|
||||||
|
String response = """
|
||||||
|
{
|
||||||
|
"choices": [{
|
||||||
|
"message": {
|
||||||
|
"content": "2026",
|
||||||
|
"reasoning_content": "读取图片中的数字"
|
||||||
|
},
|
||||||
|
"index": 0,
|
||||||
|
"finish_reason": "stop"
|
||||||
|
}],
|
||||||
|
"usage": {
|
||||||
|
"prompt_tokens": 221,
|
||||||
|
"completion_tokens": 51,
|
||||||
|
"total_tokens": 272
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
AiMessage message = DefaultAiMessageParser.getOpenAIMessageParser()
|
||||||
|
.parse(JSON.parseObject(response), context(false));
|
||||||
|
|
||||||
|
Assert.assertEquals("2026", message.getContent());
|
||||||
|
Assert.assertEquals("2026", message.getFullContent());
|
||||||
|
Assert.assertEquals("2026", message.getTextContent());
|
||||||
|
Assert.assertEquals("读取图片中的数字", message.getReasoningContent());
|
||||||
|
Assert.assertEquals("读取图片中的数字", message.getFullReasoningContent());
|
||||||
|
Assert.assertEquals(Integer.valueOf(272), message.getTotalTokens());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证流式解析继续暴露原始增量内容。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void streamingResponseShouldKeepDeltaContent() {
|
||||||
|
String response = """
|
||||||
|
{
|
||||||
|
"choices": [{
|
||||||
|
"delta": {
|
||||||
|
"content": "20",
|
||||||
|
"reasoning_content": "读取"
|
||||||
|
},
|
||||||
|
"index": 0
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
AiMessage message = DefaultAiMessageParser.getOpenAIMessageParser()
|
||||||
|
.parse(JSON.parseObject(response), context(true));
|
||||||
|
|
||||||
|
Assert.assertEquals("20", message.getContent());
|
||||||
|
Assert.assertEquals("读取", message.getReasoningContent());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建指定流式模式的解析上下文。
|
||||||
|
*
|
||||||
|
* @param streaming 是否解析流式增量
|
||||||
|
* @return 解析上下文
|
||||||
|
*/
|
||||||
|
private ChatContext context(boolean streaming) {
|
||||||
|
ChatOptions options = new ChatOptions();
|
||||||
|
options.setStreaming(streaming);
|
||||||
|
ChatContext context = new ChatContext();
|
||||||
|
context.setOptions(options);
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.easyagents.core.test.util;
|
||||||
|
|
||||||
|
import com.easyagents.core.message.UserMessage;
|
||||||
|
import com.easyagents.core.util.ImageUtil;
|
||||||
|
import org.junit.Assert;
|
||||||
|
import org.junit.Test;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图片 Data URI 转换测试。
|
||||||
|
*/
|
||||||
|
public class ImageUtilTest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证图片字节会生成符合标准的完整 Data URI。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldAddDataSchemeWhenEncodingImageBytes() {
|
||||||
|
String dataUri = ImageUtil.imageBytesToDataUri(new byte[]{1, 2, 3}, "image/png");
|
||||||
|
|
||||||
|
Assert.assertEquals("data:image/png;base64,AQID", dataUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证用户消息添加图片字节时保留完整 Data URI。
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
public void shouldStoreCompleteDataUriInUserMessage() {
|
||||||
|
UserMessage message = new UserMessage();
|
||||||
|
|
||||||
|
message.addImageBytes(new byte[]{1, 2, 3}, "image/png");
|
||||||
|
|
||||||
|
Assert.assertEquals(List.of("data:image/png;base64,AQID"), message.getImageUrls());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ public class EasyAgentsLlm implements Llm {
|
|||||||
|
|
||||||
AiMessage aiMessage = response.getMessage();
|
AiMessage aiMessage = response.getMessage();
|
||||||
if (aiMessage != null) {
|
if (aiMessage != null) {
|
||||||
return aiMessage.getContent();
|
return aiMessage.getTextContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new RuntimeException("EasyAgentsLlm can not get aiMessage!");
|
throw new RuntimeException("EasyAgentsLlm can not get aiMessage!");
|
||||||
|
|||||||
Reference in New Issue
Block a user