[ISSUE #15263] Refactor AgentSpec subscription to HTTP polling with 304, and add Skill subscription (#15264)
* feat(ai): refactor AgentSpec and Skill client to HTTP polling with 304 - Replace ConfigService push subscription with periodic HTTP polling on AgentSpec and Skill cache holders, using ETag/MD5 conditional query (304 Not Modified) to avoid full content transfers when unchanged - Add server-side support for 304 conditional response on AgentSpec and Skill query endpoints (SkillClientController, AgentSpecClientController, query result + digest utils) - Introduce SkillQueryResponse / AgentSpecQueryResponse on the client and corresponding query result types on the server - Add NacosSkillEvent, AbstractNacosSkillListener, SkillChangedEvent, SkillListenerInvoker for skill listener notification on the client - Fix double-event bug on initial subscribe: NacosAgentSpecCacheHolder and NacosSkillCacheHolder no longer publish event during initial subscribe; the first listener notification is delivered synchronously by NacosAiService to avoid racing with NotifyCenter async dispatch - Update unit tests to reflect the new single-channel notification semantics on initial subscribe - Add AgentSpecExample and SkillExample under example/ for end-to-end integration testing against a real Nacos server - Update agentspec-spec and skill-spec (en + zh-cn) accordingly * fix(ai): fix CI failures - spotless formatting and test compilation errors - Fix spotless violations in AgentSpecExample.java and SkillExample.java (blank line indentation) - Fix SkillClientControllerTest to use SkillClientOperationService (matches controller constructor) - Add missing updateStorageMd5 override in AgentSpecTypeIsolationTest and AgentSpecDeletionTest - Refactor controllers to use result.isNotModified() instead of exception-based control flow - Add notModified field to SkillQueryResult and AgentSpecQueryResult for clean 304 handling
This commit is contained in:
@@ -153,6 +153,26 @@ public class Constants {
|
||||
|
||||
public static final String SKILL_DEFAULT_NAMESPACE = "public";
|
||||
|
||||
/**
|
||||
* Resource type constant used in {@code ai_resource_version.type} for skill rows.
|
||||
*/
|
||||
public static final String RESOURCE_TYPE_SKILL = "skill";
|
||||
|
||||
/**
|
||||
* Key inside {@code ai_resource_version.storage} JSON for the published content MD5.
|
||||
*/
|
||||
public static final String STORAGE_KEY_CONTENT_MD5 = "contentMd5";
|
||||
|
||||
/**
|
||||
* Response header carrying the published skill content MD5 for client listener cache.
|
||||
*/
|
||||
public static final String HEADER_SKILL_MD5 = "X-Nacos-Skill-Md5";
|
||||
|
||||
/**
|
||||
* Response header carrying the resolved version when the client queries by label.
|
||||
*/
|
||||
public static final String HEADER_SKILL_RESOLVED_VERSION = "X-Nacos-Skill-Resolved-Version";
|
||||
|
||||
/**
|
||||
* Default max allowed size for skill zip upload (10MB).
|
||||
*
|
||||
@@ -198,6 +218,11 @@ public class Constants {
|
||||
public static final String SEARCH_ACCURATE = "accurate";
|
||||
|
||||
public static final String AGENTSPEC_DEFAULT_NAMESPACE = "public";
|
||||
|
||||
public static final String HEADER_AGENTSPEC_MD5 = "X-Nacos-AgentSpec-Md5";
|
||||
|
||||
public static final String HEADER_AGENTSPEC_RESOLVED_VERSION =
|
||||
"X-Nacos-AgentSpec-Resolved-Version";
|
||||
}
|
||||
|
||||
public static class Pipeline {
|
||||
|
||||
@@ -21,6 +21,8 @@ import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.ai.form.agentspecs.client.AgentSpecQueryForm;
|
||||
import com.alibaba.nacos.ai.form.agentspecs.client.AgentSpecSearchForm;
|
||||
import com.alibaba.nacos.ai.service.agentspecs.AgentSpecOperationService;
|
||||
import com.alibaba.nacos.ai.service.agentspecs.AgentSpecQueryResult;
|
||||
import com.alibaba.nacos.ai.utils.AgentSpecRequestUtil;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpec;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpecBasicInfo;
|
||||
import com.alibaba.nacos.api.annotation.NacosApi;
|
||||
@@ -33,6 +35,7 @@ import com.alibaba.nacos.core.model.form.PageForm;
|
||||
import com.alibaba.nacos.core.paramcheck.ExtractorManager;
|
||||
import com.alibaba.nacos.plugin.auth.constant.ActionTypes;
|
||||
import com.alibaba.nacos.plugin.auth.constant.SignType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -73,16 +76,23 @@ public class AgentSpecClientController {
|
||||
|
||||
/**
|
||||
* Get an online agentspec version by label/version/latest.
|
||||
* Supports MD5-based conditional query: returns 304 when the client cache is fresh.
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@GetMapping
|
||||
@Secured(action = ActionTypes.READ, signType = SignType.AI, apiType = ApiType.OPEN_API,
|
||||
tags = {ALLOW_ANONYMOUS})
|
||||
public Result<AgentSpec> get(AgentSpecQueryForm form) throws NacosException {
|
||||
public ResponseEntity<Result<AgentSpec>> get(AgentSpecQueryForm form)
|
||||
throws NacosException {
|
||||
form.validate();
|
||||
return Result.success(
|
||||
agentSpecOperationService.queryAgentSpec(form.getNamespaceId(), form.getName(),
|
||||
form.getVersion(),
|
||||
form.getLabel()));
|
||||
AgentSpecQueryResult result =
|
||||
agentSpecOperationService.queryAgentSpecForClient(
|
||||
form.getNamespaceId(), form.getName(), form.getVersion(),
|
||||
form.getLabel(), form.getMd5());
|
||||
if (result.isNotModified()) {
|
||||
return AgentSpecRequestUtil.buildAgentSpecNotModifiedResponse(result.getMd5());
|
||||
}
|
||||
return AgentSpecRequestUtil.buildAgentSpecResponse(result.getAgentSpec(),
|
||||
result.getMd5(), result.getResolvedVersion());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,9 +20,9 @@ import com.alibaba.nacos.api.annotation.Since;
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.ai.form.skills.client.SkillQueryForm;
|
||||
import com.alibaba.nacos.ai.param.SkillHttpParamExtractor;
|
||||
import com.alibaba.nacos.ai.service.skills.SkillOperationService;
|
||||
import com.alibaba.nacos.ai.service.skills.SkillClientOperationService;
|
||||
import com.alibaba.nacos.ai.service.skills.SkillQueryResult;
|
||||
import com.alibaba.nacos.ai.utils.SkillRequestUtil;
|
||||
import com.alibaba.nacos.api.ai.model.skills.Skill;
|
||||
import com.alibaba.nacos.api.annotation.NacosApi;
|
||||
import com.alibaba.nacos.api.common.ApiType;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
@@ -48,14 +48,18 @@ import static com.alibaba.nacos.plugin.auth.constant.Constants.Tag.ALLOW_ANONYMO
|
||||
@ExtractorManager.Extractor(httpExtractor = SkillHttpParamExtractor.class)
|
||||
public class SkillClientController {
|
||||
|
||||
private final SkillOperationService skillOperationService;
|
||||
private final SkillClientOperationService skillClientOperationService;
|
||||
|
||||
public SkillClientController(SkillOperationService skillOperationService) {
|
||||
this.skillOperationService = skillOperationService;
|
||||
public SkillClientController(SkillClientOperationService skillClientOperationService) {
|
||||
this.skillClientOperationService = skillClientOperationService;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download an online skill version as ZIP file by label/version/latest.
|
||||
*
|
||||
* <p>Supports listener-style polling: when the {@code md5} query parameter matches the
|
||||
* server-side published content MD5, the server returns HTTP 304 with the listener headers
|
||||
* ({@code ETag}/{@code X-Nacos-Skill-Md5}) so the client can keep using its local cache.
|
||||
*/
|
||||
@Since("3.2.0")
|
||||
@GetMapping
|
||||
@@ -63,9 +67,15 @@ public class SkillClientController {
|
||||
tags = {ALLOW_ANONYMOUS})
|
||||
public ResponseEntity<byte[]> get(SkillQueryForm form) throws NacosException {
|
||||
form.validate();
|
||||
Skill skill = skillOperationService.querySkill(form.getNamespaceId(), form.getName(),
|
||||
form.getVersion(),
|
||||
form.getLabel());
|
||||
return SkillRequestUtil.buildSkillZipResponse(skill);
|
||||
SkillQueryResult result = skillClientOperationService.querySkill(form.getNamespaceId(),
|
||||
form.getName(), form.getVersion(), form.getLabel(), form.getMd5());
|
||||
if (result.isNotModified()) {
|
||||
// Client-supplied MD5 equals the published one; echo it back as the ETag without
|
||||
// re-loading the skill bytes.
|
||||
return SkillRequestUtil.buildSkillNotModifiedResponse(result.getMd5(),
|
||||
result.getResolvedVersion());
|
||||
}
|
||||
return SkillRequestUtil.buildSkillZipResponseWithMd5(result.getSkill(),
|
||||
result.getMd5(), result.getResolvedVersion());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ public class AgentSpecQueryForm {
|
||||
|
||||
private String label;
|
||||
|
||||
private String md5;
|
||||
|
||||
/**
|
||||
* Validate and normalize query parameters.
|
||||
*
|
||||
@@ -83,4 +85,12 @@ public class AgentSpecQueryForm {
|
||||
public void setLabel(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String getMd5() {
|
||||
return md5;
|
||||
}
|
||||
|
||||
public void setMd5(String md5) {
|
||||
this.md5 = md5;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,12 @@ public class SkillQueryForm {
|
||||
|
||||
private String label;
|
||||
|
||||
/**
|
||||
* Optional content MD5 carried by skill listener. When provided and matches the published
|
||||
* content MD5, the server returns NOT_MODIFIED so the client may keep its local cache.
|
||||
*/
|
||||
private String md5;
|
||||
|
||||
/**
|
||||
* Validate and normalize query parameters.
|
||||
*
|
||||
@@ -82,4 +88,12 @@ public class SkillQueryForm {
|
||||
public void setLabel(String label) {
|
||||
this.label = label;
|
||||
}
|
||||
|
||||
public String getMd5() {
|
||||
return md5;
|
||||
}
|
||||
|
||||
public void setMd5(String md5) {
|
||||
this.md5 = md5;
|
||||
}
|
||||
}
|
||||
|
||||
+19
@@ -208,6 +208,25 @@ public interface AgentSpecOperationService {
|
||||
AgentSpec queryAgentSpec(String namespaceId, String name, String version, String label)
|
||||
throws NacosException;
|
||||
|
||||
/**
|
||||
* Query agentspec for client listener path with MD5-based not-modified semantics.
|
||||
*
|
||||
* <p>When {@code clientMd5} is non-blank and equals the published content MD5 of the
|
||||
* resolved version, the returned result has {@link AgentSpecQueryResult#isNotModified()}
|
||||
* set to {@code true} and {@link AgentSpecQueryResult#getAgentSpec()} left {@code null},
|
||||
* so the controller can return HTTP 304 without loading content.
|
||||
*
|
||||
* @param namespaceId namespace ID
|
||||
* @param name agentspec name
|
||||
* @param version explicit version (optional)
|
||||
* @param label route label (optional)
|
||||
* @param clientMd5 MD5 carried by the listener; may be null or blank for first poll
|
||||
* @return resolved agentspec plus its content MD5 and resolved version, or a not-modified marker
|
||||
* @throws NacosException if resolution or load fails
|
||||
*/
|
||||
AgentSpecQueryResult queryAgentSpecForClient(String namespaceId, String name,
|
||||
String version, String label, String clientMd5) throws NacosException;
|
||||
|
||||
/**
|
||||
* Create a new draft version based on latest or specified version.
|
||||
*
|
||||
|
||||
+130
-5
@@ -29,6 +29,7 @@ import com.alibaba.nacos.ai.service.resource.AiResourceManager;
|
||||
import com.alibaba.nacos.ai.service.resource.ResourceVersionInfo;
|
||||
import com.alibaba.nacos.ai.service.trace.AiResourceTraceService;
|
||||
import com.alibaba.nacos.ai.storage.NacosConfigAiResourceStorage;
|
||||
import com.alibaba.nacos.ai.utils.AgentSpecContentDigestUtils;
|
||||
import com.alibaba.nacos.ai.utils.AgentSpecSeedArchiveReader;
|
||||
import com.alibaba.nacos.ai.utils.AgentSpecZipParser;
|
||||
import com.alibaba.nacos.ai.utils.ExecutorUtils;
|
||||
@@ -680,6 +681,113 @@ public class AgentSpecOperationServiceImpl implements AgentSpecOperationService
|
||||
return loadAgentSpecFromStorage(namespaceId, name, resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query an AgentSpec for the client listener path with MD5-based not-modified semantics.
|
||||
* When {@code clientMd5} matches the stored content MD5, returns a not-modified result so the
|
||||
* controller can return HTTP 304 without loading content.
|
||||
*/
|
||||
@Override
|
||||
public AgentSpecQueryResult queryAgentSpecForClient(String namespaceId, String name,
|
||||
String version, String label, String clientMd5) throws NacosException {
|
||||
// Step 1: Resolve version via existing logic (validates meta, status, etc.)
|
||||
AiResource meta = resourceManager.findMeta(namespaceId, name, RESOURCE_TYPE_AGENTSPEC);
|
||||
if (meta == null) {
|
||||
throw new NacosApiException(NacosException.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND,
|
||||
"AgentSpec not found: " + name);
|
||||
}
|
||||
resourceManager.ensureReadableOrNotFound(meta, "AgentSpec not found: " + name);
|
||||
if (!AiResourceConstants.META_STATUS_ENABLE.equalsIgnoreCase(meta.getStatus())) {
|
||||
throw new NacosApiException(NacosException.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND,
|
||||
"AgentSpec disabled: " + name);
|
||||
}
|
||||
String resolved = AiResourceManager.resolveVersion(meta, version, label);
|
||||
if (StringUtils.isBlank(resolved)) {
|
||||
throw new NacosApiException(NacosException.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND,
|
||||
"AgentSpec version not found: " + name);
|
||||
}
|
||||
|
||||
// Step 2: Read stored contentMd5 from version row's storage JSON
|
||||
String storedMd5 = readStoredContentMd5(namespaceId, name, resolved);
|
||||
|
||||
// Step 3: Fast path — client cache is fresh
|
||||
if (StringUtils.isNotBlank(storedMd5) && StringUtils.isNotBlank(clientMd5)
|
||||
&& storedMd5.equals(clientMd5)) {
|
||||
return AgentSpecQueryResult.notModified(storedMd5, resolved);
|
||||
}
|
||||
|
||||
// Step 4: Load full AgentSpec from storage
|
||||
AiResourceVersion versionRow = resourceManager.findVersion(namespaceId, name,
|
||||
RESOURCE_TYPE_AGENTSPEC, resolved);
|
||||
if (versionRow == null || !AiResourceConstants.VERSION_STATUS_ONLINE
|
||||
.equalsIgnoreCase(versionRow.getStatus())) {
|
||||
throw new NacosApiException(NacosException.NOT_FOUND, ErrorCode.RESOURCE_NOT_FOUND,
|
||||
"AgentSpec version not online: " + name);
|
||||
}
|
||||
AgentSpec agentSpec = loadAgentSpecFromStorage(namespaceId, name, resolved);
|
||||
|
||||
// Step 5: Determine effective MD5; back-fill if missing (legacy data)
|
||||
String effectiveMd5 = storedMd5;
|
||||
if (StringUtils.isBlank(effectiveMd5)) {
|
||||
effectiveMd5 = backfillContentMd5(namespaceId, name, resolved, agentSpec);
|
||||
}
|
||||
return new AgentSpecQueryResult(agentSpec, effectiveMd5, resolved);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted contentMd5 from the version row's storage JSON column.
|
||||
*/
|
||||
private String readStoredContentMd5(String namespaceId, String name,
|
||||
String resolvedVersion) {
|
||||
if (StringUtils.isBlank(resolvedVersion)) {
|
||||
return null;
|
||||
}
|
||||
AiResourceVersion versionRow = aiResourceVersionPersistService.find(namespaceId, name,
|
||||
RESOURCE_TYPE_AGENTSPEC, resolvedVersion);
|
||||
if (versionRow == null || StringUtils.isBlank(versionRow.getStorage())) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> map = JacksonUtils.toObj(versionRow.getStorage(),
|
||||
new com.fasterxml.jackson.core.type.TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
Object md5 = map == null ? null
|
||||
: map.get(Constants.Skills.STORAGE_KEY_CONTENT_MD5);
|
||||
return md5 instanceof String ? (String) md5 : null;
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to parse storage JSON for agentspec {}@{}, ignored",
|
||||
name, resolvedVersion, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Back-fill: compute the content MD5 from the loaded AgentSpec and persist it into the
|
||||
* version row's storage column. Persistence failure is swallowed and logged.
|
||||
*/
|
||||
private String backfillContentMd5(String namespaceId, String name,
|
||||
String resolvedVersion, AgentSpec agentSpec) {
|
||||
String computed;
|
||||
try {
|
||||
computed = AgentSpecContentDigestUtils.computeContentMd5(agentSpec);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to compute content MD5 for agentspec {}@{}",
|
||||
name, resolvedVersion, e);
|
||||
return null;
|
||||
}
|
||||
if (StringUtils.isBlank(resolvedVersion)) {
|
||||
return computed;
|
||||
}
|
||||
try {
|
||||
aiResourceVersionPersistService.updateStorageMd5(namespaceId, name,
|
||||
RESOURCE_TYPE_AGENTSPEC, resolvedVersion, computed);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn(
|
||||
"Failed to back-fill content MD5 for agentspec {}@{}, response is still 200",
|
||||
name, resolvedVersion, e);
|
||||
}
|
||||
return computed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new draft version for an existing or brand-new AgentSpec.
|
||||
* For existing specs with a base version, copies storage content from that version.
|
||||
@@ -869,13 +977,14 @@ public class AgentSpecOperationServiceImpl implements AgentSpecOperationService
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a version: update version status to online.
|
||||
* Publish a version: update version status to online and compute content MD5.
|
||||
*/
|
||||
@Override
|
||||
public void publish(String namespaceId, String name, String version, boolean updateLatestLabel)
|
||||
throws NacosException {
|
||||
public void publish(String namespaceId, String name, String version,
|
||||
boolean updateLatestLabel) throws NacosException {
|
||||
resourceManager.doPublish(namespaceId, name, RESOURCE_TYPE_AGENTSPEC, version,
|
||||
updateLatestLabel);
|
||||
computeAndStoreContentMd5(namespaceId, name, version);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -883,10 +992,26 @@ public class AgentSpecOperationServiceImpl implements AgentSpecOperationService
|
||||
*/
|
||||
@Override
|
||||
public void forcePublish(String namespaceId, String name, String version,
|
||||
boolean updateLatestLabel)
|
||||
throws NacosException {
|
||||
boolean updateLatestLabel) throws NacosException {
|
||||
resourceManager.doForcePublish(namespaceId, name, RESOURCE_TYPE_AGENTSPEC, version,
|
||||
updateLatestLabel);
|
||||
computeAndStoreContentMd5(namespaceId, name, version);
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute content MD5 for a published version and persist it to the storage column.
|
||||
* Failure is logged but does not break the publish operation.
|
||||
*/
|
||||
private void computeAndStoreContentMd5(String namespaceId, String name, String version) {
|
||||
try {
|
||||
AgentSpec agentSpec = loadAgentSpecFromStorage(namespaceId, name, version);
|
||||
String md5 = AgentSpecContentDigestUtils.computeContentMd5(agentSpec);
|
||||
aiResourceVersionPersistService.updateStorageMd5(namespaceId, name,
|
||||
RESOURCE_TYPE_AGENTSPEC, version, md5);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to compute/store content MD5 for agentspec {}@{}",
|
||||
name, version, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.service.agentspecs;
|
||||
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpec;
|
||||
|
||||
/**
|
||||
* Result wrapper returned by the agentspec client query path. Carries the resolved {@link AgentSpec},
|
||||
* its published content MD5 and the resolved version string so the controller can populate
|
||||
* listener-related response headers without re-parsing metadata.
|
||||
*
|
||||
* <p>When the client-supplied MD5 matches the published one, {@link #isNotModified()} is
|
||||
* {@code true} and {@link #getAgentSpec()} is {@code null}; the controller maps this to HTTP 304
|
||||
* without loading or transferring the AgentSpec content.
|
||||
*
|
||||
* @author nacos
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public class AgentSpecQueryResult {
|
||||
|
||||
private final AgentSpec agentSpec;
|
||||
|
||||
private final String md5;
|
||||
|
||||
private final String resolvedVersion;
|
||||
|
||||
private final boolean notModified;
|
||||
|
||||
public AgentSpecQueryResult(AgentSpec agentSpec, String md5, String resolvedVersion) {
|
||||
this(agentSpec, md5, resolvedVersion, false);
|
||||
}
|
||||
|
||||
private AgentSpecQueryResult(AgentSpec agentSpec, String md5, String resolvedVersion,
|
||||
boolean notModified) {
|
||||
this.agentSpec = agentSpec;
|
||||
this.md5 = md5;
|
||||
this.resolvedVersion = resolvedVersion;
|
||||
this.notModified = notModified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a result that signals "client cache is fresh". The {@code agentSpec} payload is
|
||||
* intentionally left {@code null} so the controller can short-circuit to HTTP 304 without
|
||||
* loading content.
|
||||
*/
|
||||
public static AgentSpecQueryResult notModified(String md5, String resolvedVersion) {
|
||||
return new AgentSpecQueryResult(null, md5, resolvedVersion, true);
|
||||
}
|
||||
|
||||
public AgentSpec getAgentSpec() {
|
||||
return agentSpec;
|
||||
}
|
||||
|
||||
public String getMd5() {
|
||||
return md5;
|
||||
}
|
||||
|
||||
public String getResolvedVersion() {
|
||||
return resolvedVersion;
|
||||
}
|
||||
|
||||
public boolean isNotModified() {
|
||||
return notModified;
|
||||
}
|
||||
}
|
||||
+17
@@ -47,6 +47,23 @@ public interface AiResourceVersionPersistService {
|
||||
int updateStorageAndDesc(String namespaceId, String name, String type, String version,
|
||||
String storage, String desc);
|
||||
|
||||
/**
|
||||
* Update only the {@code contentMd5} entry inside the {@code storage} JSON column. The provider,
|
||||
* scope and files entries are preserved by performing a read-merge-write on the existing row.
|
||||
*
|
||||
* <p>Used by the skill listener path to back-fill the content MD5 for historical versions that
|
||||
* were published before the listener feature shipped.
|
||||
*
|
||||
* @param namespaceId namespace ID
|
||||
* @param name resource name
|
||||
* @param type resource type
|
||||
* @param version version string
|
||||
* @param contentMd5 content MD5 to write into {@code storage.contentMd5}
|
||||
* @return number of rows affected; {@code 0} when the version row is missing
|
||||
*/
|
||||
int updateStorageMd5(String namespaceId, String name, String type, String version,
|
||||
String contentMd5);
|
||||
|
||||
int updatePublishPipelineInfo(String namespaceId, String name, String type, String version,
|
||||
String publishPipelineInfo);
|
||||
|
||||
|
||||
+13
@@ -17,6 +17,7 @@
|
||||
package com.alibaba.nacos.ai.service.repository;
|
||||
|
||||
import com.alibaba.nacos.ai.model.AiResourceVersion;
|
||||
import com.alibaba.nacos.ai.utils.AiResourceVersionStorageJsonUtil;
|
||||
import com.alibaba.nacos.api.common.Constants;
|
||||
import com.alibaba.nacos.api.model.Page;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
@@ -208,6 +209,18 @@ public class AiResourceVersionPersistServiceImpl implements AiResourceVersionPer
|
||||
version);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateStorageMd5(String namespaceId, String name, String type, String version,
|
||||
String contentMd5) {
|
||||
AiResourceVersion existed = find(namespaceId, name, type, version);
|
||||
if (existed == null) {
|
||||
return 0;
|
||||
}
|
||||
String mergedStorage = AiResourceVersionStorageJsonUtil
|
||||
.mergeContentMd5(existed.getStorage(), contentMd5);
|
||||
return updateStorage(namespaceId, name, type, version, mergedStorage);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updatePublishPipelineInfo(String namespaceId, String name, String type,
|
||||
String version,
|
||||
|
||||
+13
@@ -17,6 +17,7 @@
|
||||
package com.alibaba.nacos.ai.service.repository;
|
||||
|
||||
import com.alibaba.nacos.ai.model.AiResourceVersion;
|
||||
import com.alibaba.nacos.ai.utils.AiResourceVersionStorageJsonUtil;
|
||||
import com.alibaba.nacos.api.common.Constants;
|
||||
import com.alibaba.nacos.api.model.Page;
|
||||
import com.alibaba.nacos.common.notify.NotifyCenter;
|
||||
@@ -240,6 +241,18 @@ public class EmbeddedAiResourceVersionPersistServiceImpl
|
||||
return (success != null && success) ? 1 : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateStorageMd5(String namespaceId, String name, String type, String version,
|
||||
String contentMd5) {
|
||||
AiResourceVersion existed = find(namespaceId, name, type, version);
|
||||
if (existed == null) {
|
||||
return 0;
|
||||
}
|
||||
String mergedStorage =
|
||||
AiResourceVersionStorageJsonUtil.mergeContentMd5(existed.getStorage(), contentMd5);
|
||||
return updateStorage(namespaceId, name, type, version, mergedStorage);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updatePublishPipelineInfo(String namespaceId, String name, String type,
|
||||
String version,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.service.skills;
|
||||
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
|
||||
/**
|
||||
* Client runtime entry-point for skill listener queries.
|
||||
*
|
||||
* <p>The implementation is responsible for resolving the requested version, comparing the
|
||||
* client-supplied content MD5 against the published one, lazily back-filling the MD5 for
|
||||
* historical versions, and signalling "client cache is fresh" via
|
||||
* {@link SkillQueryResult#isNotModified()} when the two MD5s match.
|
||||
*
|
||||
* @author nacos
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public interface SkillClientOperationService {
|
||||
|
||||
/**
|
||||
* Query a skill from the client runtime path with optional MD5-based not-modified semantics.
|
||||
*
|
||||
* <p>When {@code clientMd5} is non-blank and equals the published content MD5 of the resolved
|
||||
* version, this method returns a result whose {@link SkillQueryResult#isNotModified()} is
|
||||
* {@code true} and {@link SkillQueryResult#getSkill()} is {@code null}, so the controller can
|
||||
* translate it into HTTP 304 without loading content. Otherwise the resolved skill is returned
|
||||
* together with its content MD5 and resolved version.
|
||||
*
|
||||
* <p>If the published content MD5 is missing on the version row (e.g. legacy data published
|
||||
* before the listener feature shipped), the implementation MUST back-fill it synchronously and
|
||||
* still respond with the loaded skill — never with not-modified — when back-fill fails.
|
||||
*
|
||||
* @param namespaceId namespace
|
||||
* @param name skill name
|
||||
* @param version explicit version, may be {@code null} when {@code label} is provided
|
||||
* @param label label, may be {@code null} when {@code version} is provided
|
||||
* @param clientMd5 MD5 carried by the listener; may be {@code null} or blank for the first poll
|
||||
* @return resolved skill plus its content MD5 and resolved version, or a not-modified marker
|
||||
* @throws NacosException if resolution or load fails
|
||||
*/
|
||||
SkillQueryResult querySkill(String namespaceId, String name, String version, String label,
|
||||
String clientMd5) throws NacosException;
|
||||
}
|
||||
+182
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.service.skills;
|
||||
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.ai.model.AiResourceVersion;
|
||||
import com.alibaba.nacos.ai.model.skills.SkillIndexManifest;
|
||||
import com.alibaba.nacos.ai.service.repository.AiResourceVersionPersistService;
|
||||
import com.alibaba.nacos.ai.utils.SkillContentDigestUtils;
|
||||
import com.alibaba.nacos.api.ai.model.skills.Skill;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.api.exception.api.NacosApiException;
|
||||
import com.alibaba.nacos.api.model.v2.ErrorCode;
|
||||
import com.alibaba.nacos.common.utils.JacksonUtils;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.context.annotation.Lazy;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Skill client operation service implementation.
|
||||
*
|
||||
* <p>Implements the listener-style query path with MD5-based not-modified semantics. The
|
||||
* authoritative content MD5 is persisted alongside the version row in the
|
||||
* {@code ai_resource_version.storage} JSON column at publish time. For historical versions that
|
||||
* were published before the listener feature shipped, the MD5 is back-filled synchronously on the
|
||||
* first listener-style query (Path A).
|
||||
*
|
||||
* <p>Defensive null-fallback: if the MD5 is missing or the back-fill fails for any reason, this
|
||||
* service responds with HTTP 200 and the freshly computed MD5 instead of HTTP 304, so the client
|
||||
* is guaranteed to refresh its local cache at least once after the feature ships.
|
||||
*
|
||||
* @author nacos
|
||||
* @since 3.2.0
|
||||
*/
|
||||
@Service
|
||||
public class SkillClientOperationServiceImpl implements SkillClientOperationService {
|
||||
|
||||
private static final Logger LOGGER =
|
||||
LoggerFactory.getLogger(SkillClientOperationServiceImpl.class);
|
||||
|
||||
private final SkillOperationService skillOperationService;
|
||||
|
||||
private final SkillIndexManifestService manifestService;
|
||||
|
||||
private final AiResourceVersionPersistService aiResourceVersionPersistService;
|
||||
|
||||
public SkillClientOperationServiceImpl(@Lazy SkillOperationService skillOperationService,
|
||||
SkillIndexManifestService manifestService,
|
||||
AiResourceVersionPersistService aiResourceVersionPersistService) {
|
||||
this.skillOperationService = skillOperationService;
|
||||
this.manifestService = manifestService;
|
||||
this.aiResourceVersionPersistService = aiResourceVersionPersistService;
|
||||
}
|
||||
|
||||
@Override
|
||||
public SkillQueryResult querySkill(String namespaceId, String name, String version,
|
||||
String label, String clientMd5) throws NacosException {
|
||||
if (StringUtils.isBlank(name)) {
|
||||
throw new NacosApiException(NacosException.INVALID_PARAM, ErrorCode.PARAMETER_MISSING,
|
||||
"Required parameter `name` not present");
|
||||
}
|
||||
|
||||
// Step 1: Resolve target version up-front so we can short-circuit on MD5 match without
|
||||
// loading the full skill bytes. Manifest absence here is treated as not-found and falls
|
||||
// through to skillOperationService.querySkill which raises a consistent error message.
|
||||
String resolvedVersion = resolveVersionFromManifest(namespaceId, name, version, label);
|
||||
|
||||
// Step 2: Read the published content MD5 directly from the version row's storage JSON.
|
||||
String storedMd5 = readStoredContentMd5(namespaceId, name, resolvedVersion);
|
||||
|
||||
// Step 3: Fast path — client cache is fresh, no need to load skill bytes.
|
||||
if (StringUtils.isNotBlank(storedMd5) && StringUtils.isNotBlank(clientMd5)
|
||||
&& storedMd5.equals(clientMd5)) {
|
||||
return SkillQueryResult.notModified(storedMd5, resolvedVersion);
|
||||
}
|
||||
|
||||
// Step 4: Load the skill via the regular query path. This also validates meta visibility
|
||||
// and produces the canonical NOT_FOUND error when the resource has been deleted in flight.
|
||||
Skill skill = skillOperationService.querySkill(namespaceId, name, version, label);
|
||||
|
||||
// Step 5: Decide effective MD5. When the version row is missing the field (legacy publish
|
||||
// before the listener feature shipped), compute it from the loaded skill and back-fill the
|
||||
// storage column synchronously (Path A). The defensive null-fallback ensures we always
|
||||
// return a fresh payload in this branch — never not-modified — even if the freshly
|
||||
// computed MD5 happens to coincide with the client-supplied one.
|
||||
String effectiveMd5 = storedMd5;
|
||||
if (StringUtils.isBlank(effectiveMd5)) {
|
||||
effectiveMd5 = backfillContentMd5(namespaceId, name, resolvedVersion, skill);
|
||||
}
|
||||
return new SkillQueryResult(skill, effectiveMd5, resolvedVersion);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the target version string from the skill index manifest. Returns {@code null} when
|
||||
* the manifest is missing or the {@code version}/{@code label} cannot be resolved; callers
|
||||
* should treat a {@code null} return as "let the regular query path produce NOT_FOUND".
|
||||
*/
|
||||
private String resolveVersionFromManifest(String namespaceId, String name, String version,
|
||||
String label) {
|
||||
SkillIndexManifest manifest = manifestService.query(namespaceId, name);
|
||||
if (manifest == null) {
|
||||
return null;
|
||||
}
|
||||
return SkillIndexManifestService.resolveVersion(manifest, version, label);
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the persisted {@code contentMd5} from the version row's {@code storage} JSON column.
|
||||
* Returns {@code null} when the row is missing, the storage payload is blank, the JSON is
|
||||
* unparseable, or the {@code contentMd5} key is absent.
|
||||
*/
|
||||
private String readStoredContentMd5(String namespaceId, String name, String resolvedVersion) {
|
||||
if (StringUtils.isBlank(resolvedVersion)) {
|
||||
return null;
|
||||
}
|
||||
AiResourceVersion versionRow =
|
||||
aiResourceVersionPersistService.find(namespaceId, name,
|
||||
Constants.Skills.RESOURCE_TYPE_SKILL, resolvedVersion);
|
||||
if (versionRow == null || StringUtils.isBlank(versionRow.getStorage())) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
Map<String, Object> map = JacksonUtils.toObj(versionRow.getStorage(),
|
||||
new TypeReference<Map<String, Object>>() {
|
||||
});
|
||||
Object md5 = map == null ? null
|
||||
: map.get(Constants.Skills.STORAGE_KEY_CONTENT_MD5);
|
||||
return md5 instanceof String ? (String) md5 : null;
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to parse storage JSON for skill {}@{}, ignored",
|
||||
name, resolvedVersion, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Path-A back-fill: compute the content MD5 from the loaded skill and persist it into the
|
||||
* version row's storage column. Persistence failure is swallowed and logged; the freshly
|
||||
* computed MD5 is still returned so the response carries an authoritative fingerprint.
|
||||
*/
|
||||
private String backfillContentMd5(String namespaceId, String name, String resolvedVersion,
|
||||
Skill skill) {
|
||||
String computed;
|
||||
try {
|
||||
computed = SkillContentDigestUtils.computeContentMd5(skill);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to compute content MD5 for skill {}@{}, fall back to null",
|
||||
name, resolvedVersion, e);
|
||||
return null;
|
||||
}
|
||||
if (StringUtils.isBlank(resolvedVersion)) {
|
||||
return computed;
|
||||
}
|
||||
try {
|
||||
aiResourceVersionPersistService.updateStorageMd5(namespaceId, name,
|
||||
Constants.Skills.RESOURCE_TYPE_SKILL, resolvedVersion, computed);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to back-fill content MD5 for skill {}@{}, response is still 200",
|
||||
name, resolvedVersion, e);
|
||||
}
|
||||
return computed;
|
||||
}
|
||||
}
|
||||
+21
-8
@@ -35,6 +35,7 @@ import com.alibaba.nacos.ai.service.resource.ResourceVersionInfo;
|
||||
import com.alibaba.nacos.ai.service.trace.AiResourceTraceService;
|
||||
import com.alibaba.nacos.ai.storage.NacosConfigAiResourceStorage;
|
||||
import com.alibaba.nacos.ai.utils.ExecutorUtils;
|
||||
import com.alibaba.nacos.ai.utils.SkillContentDigestUtils;
|
||||
import com.alibaba.nacos.ai.utils.SkillRequestUtil;
|
||||
import com.alibaba.nacos.ai.utils.SkillZipParser;
|
||||
import com.alibaba.nacos.api.ai.model.skills.Skill;
|
||||
@@ -276,7 +277,8 @@ public class SkillOperationServiceImpl implements SkillOperationService {
|
||||
List<String> files = writeSkillToStorage(namespaceId, skill, version);
|
||||
|
||||
// Step 4: Insert meta + version rows with status directly set to online (published)
|
||||
String storageJson = buildStorageJson(namespaceId, skillName, version, files);
|
||||
String storageJson = buildStorageJson(namespaceId, skillName, version, files,
|
||||
SkillContentDigestUtils.computeContentMd5(skill));
|
||||
resourceManager.insertBootstrapMeta(namespaceId, skillName, RESOURCE_TYPE_SKILL,
|
||||
skill.getDescription(), null, DEFAULT_AUTHOR, from, version, storageJson);
|
||||
|
||||
@@ -460,7 +462,8 @@ public class SkillOperationServiceImpl implements SkillOperationService {
|
||||
// Normalize frontmatter before writing (overwrite = existing skill, not first create)
|
||||
SkillRequestUtil.normalizeSkillFrontmatter(skill, skill.getName(), editing, false);
|
||||
List<String> files = writeSkillToStorage(namespaceId, skill, editing);
|
||||
String storageJson = buildStorageJson(namespaceId, skill.getName(), editing, files);
|
||||
String storageJson = buildStorageJson(namespaceId, skill.getName(), editing, files,
|
||||
SkillContentDigestUtils.computeContentMd5(skill));
|
||||
if (StringUtils.isNotBlank(commitMsg)) {
|
||||
resourceManager.updateVersionStorageAndDesc(namespaceId, skill.getName(),
|
||||
RESOURCE_TYPE_SKILL, editing, storageJson, commitMsg);
|
||||
@@ -758,7 +761,8 @@ public class SkillOperationServiceImpl implements SkillOperationService {
|
||||
resourceManager.insertVersionRow(namespaceId, name, RESOURCE_TYPE_SKILL,
|
||||
StringUtils.isBlank(currentUser) ? DEFAULT_AUTHOR : currentUser,
|
||||
AiResourceConstants.VERSION_STATUS_DRAFT, newVersion, versionDesc,
|
||||
buildStorageJson(namespaceId, name, newVersion, files));
|
||||
buildStorageJson(namespaceId, name, newVersion, files,
|
||||
SkillContentDigestUtils.computeContentMd5(baseSkill)));
|
||||
|
||||
// Step 3: Update meta's editingVersion pointer
|
||||
info.setEditingVersion(newVersion);
|
||||
@@ -804,7 +808,8 @@ public class SkillOperationServiceImpl implements SkillOperationService {
|
||||
|
||||
// Step 3: Overwrite storage files with new content, update version row's storage JSON and meta description
|
||||
List<String> files = writeSkillToStorage(namespaceId, draftSkill, editing);
|
||||
String storageJson = buildStorageJson(namespaceId, name, editing, files);
|
||||
String storageJson = buildStorageJson(namespaceId, name, editing, files,
|
||||
SkillContentDigestUtils.computeContentMd5(draftSkill));
|
||||
if (StringUtils.isNotBlank(commitMsg)) {
|
||||
resourceManager.updateVersionStorageAndDesc(namespaceId, name, RESOURCE_TYPE_SKILL,
|
||||
editing,
|
||||
@@ -1168,7 +1173,8 @@ public class SkillOperationServiceImpl implements SkillOperationService {
|
||||
resourceManager.insertVersionRow(namespaceId, skillName, RESOURCE_TYPE_SKILL,
|
||||
StringUtils.isBlank(currentUser) ? DEFAULT_AUTHOR : currentUser,
|
||||
AiResourceConstants.VERSION_STATUS_DRAFT, version, versionDesc,
|
||||
buildStorageJson(namespaceId, skillName, version, files));
|
||||
buildStorageJson(namespaceId, skillName, version, files,
|
||||
SkillContentDigestUtils.computeContentMd5(skill)));
|
||||
|
||||
// 3) create or update meta for editingVersion
|
||||
resourceManager.initOrUpdateMetaForDraft(namespaceId, skillName, RESOURCE_TYPE_SKILL,
|
||||
@@ -1185,14 +1191,21 @@ public class SkillOperationServiceImpl implements SkillOperationService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Build storage metadata JSON for version row (provider + scope + file list).
|
||||
* Build storage metadata JSON for version row (provider + scope + file list + optional contentMd5).
|
||||
*
|
||||
* @param contentMd5 published content MD5; may be {@code null} or blank when the caller does not
|
||||
* yet need to persist the listener-related fingerprint
|
||||
*/
|
||||
private static String buildStorageJson(String namespaceId, String skillName, String version,
|
||||
List<String> files) {
|
||||
Map<String, Object> json = new HashMap<>(4);
|
||||
List<String> files, String contentMd5) {
|
||||
Map<String, Object> json = new LinkedHashMap<>(8);
|
||||
json.put("provider", resolveSkillStorageProvider());
|
||||
json.put("scope", namespaceId + ":" + skillName + ":" + version);
|
||||
json.put("files", files);
|
||||
if (StringUtils.isNotBlank(contentMd5)) {
|
||||
json.put(com.alibaba.nacos.ai.constant.Constants.Skills.STORAGE_KEY_CONTENT_MD5,
|
||||
contentMd5);
|
||||
}
|
||||
return JacksonUtils.toJson(json);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.service.skills;
|
||||
|
||||
import com.alibaba.nacos.api.ai.model.skills.Skill;
|
||||
|
||||
/**
|
||||
* Result wrapper returned by the skill listener path. It carries the resolved {@link Skill}, its
|
||||
* published content MD5 and the resolved version string so that the controller can populate the
|
||||
* listener-related response headers without re-parsing the manifest.
|
||||
*
|
||||
* <p>When the client-supplied MD5 matches the published one, {@link #isNotModified()} is
|
||||
* {@code true} and {@link #getSkill()} is {@code null}; the controller maps this to HTTP 304
|
||||
* without loading or transferring the skill bytes.
|
||||
*
|
||||
* @author nacos
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public class SkillQueryResult {
|
||||
|
||||
private final Skill skill;
|
||||
|
||||
private final String md5;
|
||||
|
||||
private final String resolvedVersion;
|
||||
|
||||
private final boolean notModified;
|
||||
|
||||
public SkillQueryResult(Skill skill, String md5, String resolvedVersion) {
|
||||
this(skill, md5, resolvedVersion, false);
|
||||
}
|
||||
|
||||
private SkillQueryResult(Skill skill, String md5, String resolvedVersion, boolean notModified) {
|
||||
this.skill = skill;
|
||||
this.md5 = md5;
|
||||
this.resolvedVersion = resolvedVersion;
|
||||
this.notModified = notModified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a result that signals "client cache is fresh". The {@code skill} payload is intentionally
|
||||
* left {@code null} so the controller can short-circuit to HTTP 304 without loading content.
|
||||
*/
|
||||
public static SkillQueryResult notModified(String md5, String resolvedVersion) {
|
||||
return new SkillQueryResult(null, md5, resolvedVersion, true);
|
||||
}
|
||||
|
||||
public Skill getSkill() {
|
||||
return skill;
|
||||
}
|
||||
|
||||
public String getMd5() {
|
||||
return md5;
|
||||
}
|
||||
|
||||
public String getResolvedVersion() {
|
||||
return resolvedVersion;
|
||||
}
|
||||
|
||||
public boolean isNotModified() {
|
||||
return notModified;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.utils;
|
||||
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpec;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpecResource;
|
||||
import com.alibaba.nacos.common.utils.MD5Utils;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Utility for computing the canonical content MD5 of a published {@link AgentSpec}.
|
||||
*
|
||||
* <p>The MD5 is computed once at publish time and persisted into the {@code storage.contentMd5}
|
||||
* field of the corresponding {@code ai_resource_version} row, so the listener path never
|
||||
* recomputes it at runtime.
|
||||
*
|
||||
* <p>The hash input covers the main content (description + content field) plus all referenced
|
||||
* resources. To stay deterministic across publishes, resources are iterated in ascending order
|
||||
* of their map keys, and a single {@code 0x00} byte is used as the field separator.
|
||||
*
|
||||
* <p>Format (all bytes are UTF-8 unless explicitly stated):
|
||||
* <pre>
|
||||
* description UTF-8 bytes
|
||||
* 0x00
|
||||
* content UTF-8 bytes
|
||||
* 0x00
|
||||
* for each resource sorted by map key:
|
||||
* key UTF-8 bytes
|
||||
* 0x00
|
||||
* resource.getContent() UTF-8 bytes (empty string for null)
|
||||
* 0x00
|
||||
* </pre>
|
||||
*
|
||||
* @author nacos
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public final class AgentSpecContentDigestUtils {
|
||||
|
||||
private static final byte SEPARATOR = 0x00;
|
||||
|
||||
private AgentSpecContentDigestUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the canonical content MD5 for an agentspec.
|
||||
*
|
||||
* @param agentSpec the agentspec object; must not be {@code null}
|
||||
* @return lowercase hex MD5 string
|
||||
* @throws IllegalArgumentException when {@code agentSpec} is null
|
||||
*/
|
||||
public static String computeContentMd5(AgentSpec agentSpec) {
|
||||
if (agentSpec == null) {
|
||||
throw new IllegalArgumentException(
|
||||
"AgentSpec cannot be null when computing content MD5");
|
||||
}
|
||||
try {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
writeField(buffer, agentSpec.getDescription());
|
||||
buffer.write(SEPARATOR);
|
||||
writeField(buffer, agentSpec.getContent());
|
||||
buffer.write(SEPARATOR);
|
||||
|
||||
Map<String, AgentSpecResource> resources = agentSpec.getResource();
|
||||
if (resources != null && !resources.isEmpty()) {
|
||||
List<String> sortedKeys = new ArrayList<>(resources.keySet());
|
||||
Collections.sort(sortedKeys);
|
||||
for (String key : sortedKeys) {
|
||||
buffer.write(key.getBytes(StandardCharsets.UTF_8));
|
||||
buffer.write(SEPARATOR);
|
||||
AgentSpecResource resource = resources.get(key);
|
||||
writeField(buffer,
|
||||
resource != null ? resource.getContent() : null);
|
||||
buffer.write(SEPARATOR);
|
||||
}
|
||||
}
|
||||
return MD5Utils.md5Hex(buffer.toByteArray());
|
||||
} catch (IOException e) {
|
||||
throw new IllegalStateException(
|
||||
"Failed to assemble agentspec content for MD5", e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException(
|
||||
"MD5 algorithm not available in current JVM", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeField(ByteArrayOutputStream buffer, String value)
|
||||
throws IOException {
|
||||
if (StringUtils.isNotBlank(value)) {
|
||||
buffer.write(value.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16,17 +16,22 @@
|
||||
|
||||
package com.alibaba.nacos.ai.utils;
|
||||
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.ai.form.agentspecs.admin.AgentSpecDetailForm;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpec;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.api.exception.api.NacosApiException;
|
||||
import com.alibaba.nacos.api.exception.runtime.NacosDeserializationException;
|
||||
import com.alibaba.nacos.api.model.v2.ErrorCode;
|
||||
import com.alibaba.nacos.api.model.v2.Result;
|
||||
import com.alibaba.nacos.common.utils.JacksonUtils;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
import java.io.IOException;
|
||||
@@ -108,8 +113,49 @@ public class AgentSpecRequestUtil {
|
||||
try {
|
||||
return file.getBytes();
|
||||
} catch (IOException e) {
|
||||
throw new NacosApiException(NacosException.SERVER_ERROR, ErrorCode.PARSING_DATA_FAILED,
|
||||
throw new NacosApiException(NacosException.SERVER_ERROR,
|
||||
ErrorCode.PARSING_DATA_FAILED,
|
||||
"Failed to read file: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an HTTP 200 response carrying the AgentSpec with listener-related headers.
|
||||
*
|
||||
* @param agentSpec the AgentSpec object
|
||||
* @param md5 published content MD5
|
||||
* @param resolvedVersion resolved version string
|
||||
* @return ResponseEntity with status 200 and headers
|
||||
*/
|
||||
public static ResponseEntity<Result<AgentSpec>> buildAgentSpecResponse(
|
||||
AgentSpec agentSpec, String md5, String resolvedVersion) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
applyListenerHeaders(headers, md5, resolvedVersion);
|
||||
return new ResponseEntity<>(Result.success(agentSpec), headers, HttpStatus.OK);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an HTTP 304 Not Modified response with listener-related headers.
|
||||
*
|
||||
* @param md5 published content MD5
|
||||
* @return ResponseEntity with status 304 and headers
|
||||
*/
|
||||
public static ResponseEntity<Result<AgentSpec>> buildAgentSpecNotModifiedResponse(
|
||||
String md5) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
applyListenerHeaders(headers, md5, null);
|
||||
return new ResponseEntity<>(headers, HttpStatus.NOT_MODIFIED);
|
||||
}
|
||||
|
||||
private static void applyListenerHeaders(HttpHeaders headers, String md5,
|
||||
String resolvedVersion) {
|
||||
if (StringUtils.isNotBlank(md5)) {
|
||||
headers.add(HttpHeaders.ETAG, md5);
|
||||
headers.add(Constants.AgentSpecs.HEADER_AGENTSPEC_MD5, md5);
|
||||
}
|
||||
if (StringUtils.isNotBlank(resolvedVersion)) {
|
||||
headers.add(Constants.AgentSpecs.HEADER_AGENTSPEC_RESOLVED_VERSION,
|
||||
resolvedVersion);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.utils;
|
||||
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.common.utils.JacksonUtils;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Pure helpers to manipulate the JSON payload stored in
|
||||
* {@code ai_resource_version.storage}.
|
||||
*
|
||||
* <p>This logic is independent of the storage backend (external JDBC vs embedded
|
||||
* Derby + Raft), so it lives here as a stateless utility instead of being
|
||||
* duplicated across {@code AiResourceVersionPersistServiceImpl} and
|
||||
* {@code EmbeddedAiResourceVersionPersistServiceImpl}.</p>
|
||||
*/
|
||||
public final class AiResourceVersionStorageJsonUtil {
|
||||
|
||||
private AiResourceVersionStorageJsonUtil() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge a new {@code contentMd5} into the existing {@code storage} JSON while preserving the
|
||||
* other entries (provider/scope/files/...). Returns a JSON string suitable for the
|
||||
* {@code storage} column.
|
||||
*
|
||||
* @param existingStorageJson current JSON value of the {@code storage} column; may be blank
|
||||
* @param contentMd5 MD5 to write under {@link Constants.Skills#STORAGE_KEY_CONTENT_MD5}
|
||||
* @return merged JSON string with the {@code contentMd5} entry set
|
||||
*/
|
||||
public static String mergeContentMd5(String existingStorageJson, String contentMd5) {
|
||||
Map<String, Object> map;
|
||||
if (StringUtils.isBlank(existingStorageJson)) {
|
||||
map = new LinkedHashMap<>();
|
||||
} else {
|
||||
try {
|
||||
map = JacksonUtils.toObj(existingStorageJson,
|
||||
new TypeReference<LinkedHashMap<String, Object>>() {
|
||||
});
|
||||
} catch (Exception e) {
|
||||
map = new LinkedHashMap<>();
|
||||
}
|
||||
}
|
||||
map.put(Constants.Skills.STORAGE_KEY_CONTENT_MD5, contentMd5);
|
||||
return JacksonUtils.toJson(map);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.ai.utils;
|
||||
|
||||
import com.alibaba.nacos.api.ai.model.skills.Skill;
|
||||
import com.alibaba.nacos.api.ai.model.skills.SkillResource;
|
||||
import com.alibaba.nacos.common.utils.MD5Utils;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Utility for computing the canonical content MD5 of a published {@link Skill}.
|
||||
*
|
||||
* <p>The MD5 is computed once at publish time and persisted into the {@code storage.contentMd5}
|
||||
* field of the corresponding {@code ai_resource_version} row, so the listener path never recomputes
|
||||
* it at runtime.
|
||||
*
|
||||
* <p>The hash input covers SKILL.md plus all referenced resources (the same byte universe that
|
||||
* goes into the downloadable ZIP). To stay deterministic across publishes, resources are iterated
|
||||
* in ascending order of their {@link SkillResource#getResourceIdentifier() resource identifier},
|
||||
* and a single {@code 0x00} byte is used as the field separator.
|
||||
*
|
||||
* <p>Format (all bytes are UTF-8 unless explicitly stated):
|
||||
* <pre>
|
||||
* skillMd UTF-8 bytes
|
||||
* 0x00
|
||||
* for each resource sorted by getResourceIdentifier():
|
||||
* resource.getResourceIdentifier() UTF-8 bytes
|
||||
* 0x00
|
||||
* resource.getContent() UTF-8 bytes (empty string for null content)
|
||||
* 0x00
|
||||
* </pre>
|
||||
*
|
||||
* @author nacos
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public final class SkillContentDigestUtils {
|
||||
|
||||
private static final byte SEPARATOR = 0x00;
|
||||
|
||||
private SkillContentDigestUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the canonical content MD5 for a skill.
|
||||
*
|
||||
* @param skill the skill object; must not be {@code null}
|
||||
* @return lowercase hex MD5 string
|
||||
* @throws IllegalArgumentException when {@code skill} is null
|
||||
*/
|
||||
public static String computeContentMd5(Skill skill) {
|
||||
if (skill == null) {
|
||||
throw new IllegalArgumentException("Skill cannot be null when computing content MD5");
|
||||
}
|
||||
try {
|
||||
ByteArrayOutputStream buffer = new ByteArrayOutputStream();
|
||||
String skillMd = skill.getSkillMd();
|
||||
if (skillMd != null) {
|
||||
buffer.write(skillMd.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
buffer.write(SEPARATOR);
|
||||
|
||||
Map<String, SkillResource> resources = skill.getResource();
|
||||
if (resources != null && !resources.isEmpty()) {
|
||||
List<SkillResource> sorted = new ArrayList<>(resources.values());
|
||||
sorted.removeIf(r -> r == null || StringUtils.isBlank(r.getName()));
|
||||
Collections.sort(sorted, (a, b) -> {
|
||||
String ka = safeIdentifier(a);
|
||||
String kb = safeIdentifier(b);
|
||||
return ka.compareTo(kb);
|
||||
});
|
||||
for (SkillResource resource : sorted) {
|
||||
buffer.write(safeIdentifier(resource).getBytes(StandardCharsets.UTF_8));
|
||||
buffer.write(SEPARATOR);
|
||||
String content = resource.getContent();
|
||||
if (content != null) {
|
||||
buffer.write(content.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
buffer.write(SEPARATOR);
|
||||
}
|
||||
}
|
||||
return MD5Utils.md5Hex(buffer.toByteArray());
|
||||
} catch (IOException e) {
|
||||
// ByteArrayOutputStream never throws; keep checked-style for completeness.
|
||||
throw new IllegalStateException("Failed to assemble skill content for MD5", e);
|
||||
} catch (NoSuchAlgorithmException e) {
|
||||
throw new IllegalStateException("MD5 algorithm not available in current JVM", e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String safeIdentifier(SkillResource resource) {
|
||||
String id = resource.getResourceIdentifier();
|
||||
return id == null ? "" : id;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package com.alibaba.nacos.ai.utils;
|
||||
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.ai.form.skills.admin.SkillDetailForm;
|
||||
import com.alibaba.nacos.api.ai.model.skills.Skill;
|
||||
import com.alibaba.nacos.api.ai.model.skills.SkillUtils;
|
||||
@@ -30,6 +31,7 @@ import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.multipart.MultipartFile;
|
||||
|
||||
@@ -61,6 +63,7 @@ public class SkillRequestUtil {
|
||||
try {
|
||||
byte[] zipBytes = SkillUtils.toZipBytes(skill);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.parseMediaType("application/zip"));
|
||||
headers.add("Content-Disposition", "attachment;filename=" + skill.getName() + ".zip");
|
||||
return new ResponseEntity<>(zipBytes, headers, HttpStatus.OK);
|
||||
} catch (Exception e) {
|
||||
@@ -69,6 +72,60 @@ public class SkillRequestUtil {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a ZIP download {@link ResponseEntity} together with the listener-related headers
|
||||
* ({@code ETag}, {@code X-Nacos-Skill-Md5} and {@code X-Nacos-Skill-Resolved-Version}).
|
||||
*
|
||||
* <p>{@code md5} and {@code resolvedVersion} may be blank; only non-blank values are emitted as
|
||||
* headers so legacy paths that do not yet carry MD5 keep their existing response shape.
|
||||
*
|
||||
* @param skill the Skill object
|
||||
* @param md5 published content MD5, optional
|
||||
* @param resolvedVersion resolved version when caller queries by label, optional
|
||||
* @return ResponseEntity containing ZIP bytes with proper headers
|
||||
* @throws NacosException if ZIP creation fails
|
||||
*/
|
||||
public static ResponseEntity<byte[]> buildSkillZipResponseWithMd5(Skill skill, String md5,
|
||||
String resolvedVersion) throws NacosException {
|
||||
try {
|
||||
byte[] zipBytes = SkillUtils.toZipBytes(skill);
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
headers.setContentType(MediaType.parseMediaType("application/zip"));
|
||||
headers.add("Content-Disposition", "attachment;filename=" + skill.getName() + ".zip");
|
||||
applyListenerHeaders(headers, md5, resolvedVersion);
|
||||
return new ResponseEntity<>(zipBytes, headers, HttpStatus.OK);
|
||||
} catch (Exception e) {
|
||||
throw new NacosException(NacosException.SERVER_ERROR,
|
||||
"Failed to create skill zip: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an HTTP 304 Not Modified response carrying the listener-related headers so the client
|
||||
* can refresh its local cache metadata even when the body is empty.
|
||||
*
|
||||
* @param md5 published content MD5
|
||||
* @param resolvedVersion resolved version, may be {@code null}
|
||||
* @return ResponseEntity with status 304 and listener headers
|
||||
*/
|
||||
public static ResponseEntity<byte[]> buildSkillNotModifiedResponse(String md5,
|
||||
String resolvedVersion) {
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
applyListenerHeaders(headers, md5, resolvedVersion);
|
||||
return new ResponseEntity<>(headers, HttpStatus.NOT_MODIFIED);
|
||||
}
|
||||
|
||||
private static void applyListenerHeaders(HttpHeaders headers, String md5,
|
||||
String resolvedVersion) {
|
||||
if (StringUtils.isNotBlank(md5)) {
|
||||
headers.add(HttpHeaders.ETAG, md5);
|
||||
headers.add(Constants.Skills.HEADER_SKILL_MD5, md5);
|
||||
}
|
||||
if (StringUtils.isNotBlank(resolvedVersion)) {
|
||||
headers.add(Constants.Skills.HEADER_SKILL_RESOLVED_VERSION, resolvedVersion);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Skill request form to {@link Skill}.
|
||||
*
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
package com.alibaba.nacos.ai.controller;
|
||||
|
||||
import com.alibaba.nacos.ai.constant.Constants;
|
||||
import com.alibaba.nacos.ai.service.skills.SkillOperationService;
|
||||
import com.alibaba.nacos.ai.service.skills.SkillClientOperationService;
|
||||
import com.alibaba.nacos.ai.service.skills.SkillQueryResult;
|
||||
import com.alibaba.nacos.api.ai.model.skills.Skill;
|
||||
import com.alibaba.nacos.api.exception.api.NacosApiException;
|
||||
import com.alibaba.nacos.sys.env.EnvUtil;
|
||||
@@ -67,13 +68,13 @@ class SkillClientControllerTest {
|
||||
private ConfigurableEnvironment cachedEnvironment;
|
||||
|
||||
@Mock
|
||||
private SkillOperationService skillOperationService;
|
||||
private SkillClientOperationService skillClientOperationService;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
cachedEnvironment = EnvUtil.getEnvironment();
|
||||
EnvUtil.setEnvironment(new StandardEnvironment());
|
||||
skillClientController = new SkillClientController(skillOperationService);
|
||||
skillClientController = new SkillClientController(skillClientOperationService);
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(skillClientController).build();
|
||||
}
|
||||
|
||||
@@ -91,28 +92,22 @@ class SkillClientControllerTest {
|
||||
|
||||
@Test
|
||||
void testGetSkillByNameSuccess() throws Exception {
|
||||
Skill skill = new Skill();
|
||||
skill.setName("test-skill");
|
||||
skill.setDescription("desc");
|
||||
skill.setSkillMd("---\nname: test-skill\ndescription: desc\n---\n\ninstruction");
|
||||
when(skillOperationService.querySkill(eq("public"), eq("test-skill"), isNull(), isNull()))
|
||||
.thenReturn(skill);
|
||||
when(skillClientOperationService.querySkill(eq("public"), eq("test-skill"), isNull(),
|
||||
isNull(), isNull()))
|
||||
.thenReturn(new SkillQueryResult(newSkill(), "md5-1", "v1"));
|
||||
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get(SKILL_CLIENT_PATH)
|
||||
.param("name", "test-skill");
|
||||
MockHttpServletResponse response = mockMvc.perform(builder).andReturn().getResponse();
|
||||
assertEquals(200, response.getStatus());
|
||||
// Response is a ZIP file
|
||||
assertEquals("application/octet-stream", response.getContentType());
|
||||
assertEquals("application/zip", response.getContentType());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillByLabelSuccess() throws Exception {
|
||||
Skill skill = new Skill();
|
||||
skill.setName("test-skill");
|
||||
skill.setSkillMd("---\nname: test-skill\ndescription: desc\n---\n\ninstruction");
|
||||
when(skillOperationService.querySkill(eq("public"), eq("test-skill"), isNull(),
|
||||
eq("stable")))
|
||||
.thenReturn(skill);
|
||||
when(skillClientOperationService.querySkill(eq("public"), eq("test-skill"), isNull(),
|
||||
eq("stable"), isNull()))
|
||||
.thenReturn(new SkillQueryResult(newSkill(), "md5-2", "v1"));
|
||||
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get(SKILL_CLIENT_PATH)
|
||||
.param("name", "test-skill").param("label", "stable");
|
||||
MockHttpServletResponse response = mockMvc.perform(builder).andReturn().getResponse();
|
||||
@@ -121,11 +116,9 @@ class SkillClientControllerTest {
|
||||
|
||||
@Test
|
||||
void testGetSkillByVersionSuccess() throws Exception {
|
||||
Skill skill = new Skill();
|
||||
skill.setName("test-skill");
|
||||
skill.setSkillMd("---\nname: test-skill\ndescription: desc\n---\n\ninstruction");
|
||||
when(skillOperationService.querySkill(eq("public"), eq("test-skill"), eq("v2"), isNull()))
|
||||
.thenReturn(skill);
|
||||
when(skillClientOperationService.querySkill(eq("public"), eq("test-skill"), eq("v2"),
|
||||
isNull(), isNull()))
|
||||
.thenReturn(new SkillQueryResult(newSkill(), "md5-3", "v2"));
|
||||
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get(SKILL_CLIENT_PATH)
|
||||
.param("name", "test-skill").param("version", "v2");
|
||||
MockHttpServletResponse response = mockMvc.perform(builder).andReturn().getResponse();
|
||||
@@ -134,18 +127,34 @@ class SkillClientControllerTest {
|
||||
|
||||
@Test
|
||||
void testGetSkillWithNamespaceId() throws Exception {
|
||||
Skill skill = new Skill();
|
||||
skill.setName("test-skill");
|
||||
skill.setSkillMd("---\nname: test-skill\ndescription: desc\n---\n\ninstruction");
|
||||
when(
|
||||
skillOperationService.querySkill(eq("custom-ns"), eq("test-skill"), isNull(), isNull()))
|
||||
.thenReturn(skill);
|
||||
when(skillClientOperationService.querySkill(eq("custom-ns"), eq("test-skill"), isNull(),
|
||||
isNull(), isNull()))
|
||||
.thenReturn(new SkillQueryResult(newSkill(), "md5-4", "v1"));
|
||||
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get(SKILL_CLIENT_PATH)
|
||||
.param("name", "test-skill").param("namespaceId", "custom-ns");
|
||||
MockHttpServletResponse response = mockMvc.perform(builder).andReturn().getResponse();
|
||||
assertEquals(200, response.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testGetSkillNotModified() throws Exception {
|
||||
when(skillClientOperationService.querySkill(eq("public"), eq("test-skill"), isNull(),
|
||||
isNull(), eq("md5-cached")))
|
||||
.thenReturn(SkillQueryResult.notModified("md5-cached", "v1"));
|
||||
MockHttpServletRequestBuilder builder = MockMvcRequestBuilders.get(SKILL_CLIENT_PATH)
|
||||
.param("name", "test-skill").param("md5", "md5-cached");
|
||||
MockHttpServletResponse response = mockMvc.perform(builder).andReturn().getResponse();
|
||||
assertEquals(304, response.getStatus());
|
||||
}
|
||||
|
||||
private static Skill newSkill() {
|
||||
Skill skill = new Skill();
|
||||
skill.setName("test-skill");
|
||||
skill.setDescription("desc");
|
||||
skill.setSkillMd("---\nname: test-skill\ndescription: desc\n---\n\ninstruction");
|
||||
return skill;
|
||||
}
|
||||
|
||||
private void assertServletException(Class<? extends Exception> expectedException,
|
||||
Executable executable,
|
||||
String expectedMessage) throws Throwable {
|
||||
|
||||
@@ -408,6 +408,12 @@ class AgentSpecDeletionTest {
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateStorageMd5(String namespaceId, String name, String type, String version,
|
||||
String contentMd5) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<AiResourceVersion> list(String namespaceId, String name, String type,
|
||||
String status, int pageNo,
|
||||
|
||||
+6
@@ -386,6 +386,12 @@ class AgentSpecTypeIsolationTest {
|
||||
.findFirst().orElse(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int updateStorageMd5(String namespaceId, String name, String type, String version,
|
||||
String contentMd5) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Page<AiResourceVersion> list(String namespaceId, String name, String type,
|
||||
String status, int pageNo,
|
||||
|
||||
@@ -20,6 +20,7 @@ import com.alibaba.nacos.api.annotation.Since;
|
||||
import com.alibaba.nacos.api.ai.listener.AbstractNacosAgentSpecListener;
|
||||
import com.alibaba.nacos.api.ai.listener.AbstractNacosMcpServerListener;
|
||||
import com.alibaba.nacos.api.ai.listener.AbstractNacosPromptListener;
|
||||
import com.alibaba.nacos.api.ai.listener.AbstractNacosSkillListener;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpec;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpEndpointSpec;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpResourceSpecification;
|
||||
@@ -370,6 +371,32 @@ public interface AiService extends A2aService {
|
||||
void unsubscribePrompt(String promptKey, String version, String label,
|
||||
AbstractNacosPromptListener promptListener) throws NacosException;
|
||||
|
||||
/**
|
||||
* Subscribe skill changes.
|
||||
*
|
||||
* @param skillName skill name
|
||||
* @param version target skill version, optional
|
||||
* @param label target skill label, optional
|
||||
* @param skillListener listener for skill changes
|
||||
* @return current skill ZIP bytes, may be {@code null} when the skill is not found
|
||||
* @throws NacosException if request parameter is invalid or handle error
|
||||
*/
|
||||
byte[] subscribeSkill(String skillName, String version, String label,
|
||||
AbstractNacosSkillListener skillListener) throws NacosException;
|
||||
|
||||
/**
|
||||
* Un-subscribe skill changes.
|
||||
*
|
||||
* @param skillName skill name
|
||||
* @param version target skill version, optional
|
||||
* @param label target skill label, optional
|
||||
* @param skillListener listener previously registered via
|
||||
* {@link #subscribeSkill(String, String, String, AbstractNacosSkillListener)}
|
||||
* @throws NacosException if request parameter is invalid or handle error
|
||||
*/
|
||||
void unsubscribeSkill(String skillName, String version, String label,
|
||||
AbstractNacosSkillListener skillListener) throws NacosException;
|
||||
|
||||
/**
|
||||
* Shutdown the AI service and close resources.
|
||||
*
|
||||
|
||||
@@ -72,6 +72,11 @@ public class AiConstants {
|
||||
|
||||
public static final String AI_PROMPT_CACHE_UPDATE_INTERVAL = "nacosAiPromptCacheUpdateInterval";
|
||||
|
||||
public static final String AI_SKILL_CACHE_UPDATE_INTERVAL = "nacosAiSkillCacheUpdateInterval";
|
||||
|
||||
public static final String AI_AGENTSPEC_CACHE_UPDATE_INTERVAL =
|
||||
"nacosAiAgentSpecCacheUpdateInterval";
|
||||
|
||||
public static final long DEFAULT_AI_CACHE_UPDATE_INTERVAL = 10000L;
|
||||
|
||||
public static class A2a {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.api.ai.listener;
|
||||
|
||||
/**
|
||||
* Nacos AI module skill event listener.
|
||||
*
|
||||
* <p>Extend this class to receive skill change notifications.</p>
|
||||
*
|
||||
* @author nacos
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public abstract class AbstractNacosSkillListener implements NacosAiListener<NacosSkillEvent> {
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.api.ai.listener;
|
||||
|
||||
/**
|
||||
* Nacos AI module skill event.
|
||||
*
|
||||
* <p>Triggered when a subscribed skill changes on the server side. The {@link #zipBytes}
|
||||
* payload carries the freshly downloaded skill ZIP archive (SKILL.md plus resources); the
|
||||
* {@link #md5} field is the server-published content fingerprint suitable for diffing
|
||||
* subsequent revisions or persisting alongside the local cache.
|
||||
*
|
||||
* @author nacos
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public class NacosSkillEvent implements NacosAiEvent {
|
||||
|
||||
private final String skillName;
|
||||
|
||||
private final byte[] zipBytes;
|
||||
|
||||
private final String md5;
|
||||
|
||||
private final String resolvedVersion;
|
||||
|
||||
public NacosSkillEvent(String skillName, byte[] zipBytes, String md5, String resolvedVersion) {
|
||||
this.skillName = skillName;
|
||||
this.zipBytes = zipBytes;
|
||||
this.md5 = md5;
|
||||
this.resolvedVersion = resolvedVersion;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the skill name.
|
||||
*
|
||||
* @return skill name
|
||||
*/
|
||||
public String getSkillName() {
|
||||
return skillName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the skill ZIP payload, may be {@code null} when the skill has been deleted on the server.
|
||||
*
|
||||
* @return skill ZIP byte array, or {@code null} if the skill no longer exists
|
||||
*/
|
||||
public byte[] getZipBytes() {
|
||||
return zipBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the published content MD5 of this skill revision, may be {@code null} for delete events
|
||||
* or when the server response did not carry the fingerprint header.
|
||||
*
|
||||
* @return content MD5
|
||||
*/
|
||||
public String getMd5() {
|
||||
return md5;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the resolved version string when the listener was registered against a label, may be
|
||||
* {@code null} when the request used an explicit version or the response did not carry the
|
||||
* resolved version header.
|
||||
*
|
||||
* @return resolved version, optional
|
||||
*/
|
||||
public String getResolvedVersion() {
|
||||
return resolvedVersion;
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import com.alibaba.nacos.api.ai.listener.AbstractNacosAgentCardListener;
|
||||
import com.alibaba.nacos.api.ai.listener.AbstractNacosAgentSpecListener;
|
||||
import com.alibaba.nacos.api.ai.listener.AbstractNacosMcpServerListener;
|
||||
import com.alibaba.nacos.api.ai.listener.AbstractNacosPromptListener;
|
||||
import com.alibaba.nacos.api.ai.listener.AbstractNacosSkillListener;
|
||||
import com.alibaba.nacos.api.ai.model.a2a.AgentCard;
|
||||
import com.alibaba.nacos.api.ai.model.a2a.AgentCardDetailInfo;
|
||||
import com.alibaba.nacos.api.ai.model.a2a.AgentEndpoint;
|
||||
@@ -205,4 +206,15 @@ public class NacosAiService implements AiService {
|
||||
public void unsubscribePrompt(String promptKey, String version, String label,
|
||||
AbstractNacosPromptListener promptListener) throws NacosException {
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] subscribeSkill(String skillName, String version, String label,
|
||||
AbstractNacosSkillListener skillListener) throws NacosException {
|
||||
return new byte[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unsubscribeSkill(String skillName, String version, String label,
|
||||
AbstractNacosSkillListener skillListener) throws NacosException {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,10 +23,12 @@ import com.alibaba.nacos.api.ai.listener.AbstractNacosAgentCardListener;
|
||||
import com.alibaba.nacos.api.ai.listener.AbstractNacosAgentSpecListener;
|
||||
import com.alibaba.nacos.api.ai.listener.AbstractNacosMcpServerListener;
|
||||
import com.alibaba.nacos.api.ai.listener.AbstractNacosPromptListener;
|
||||
import com.alibaba.nacos.api.ai.listener.AbstractNacosSkillListener;
|
||||
import com.alibaba.nacos.api.ai.listener.NacosAgentCardEvent;
|
||||
import com.alibaba.nacos.api.ai.listener.NacosAgentSpecEvent;
|
||||
import com.alibaba.nacos.api.ai.listener.NacosMcpServerEvent;
|
||||
import com.alibaba.nacos.api.ai.listener.NacosPromptEvent;
|
||||
import com.alibaba.nacos.api.ai.listener.NacosSkillEvent;
|
||||
import com.alibaba.nacos.api.ai.model.a2a.AgentCard;
|
||||
import com.alibaba.nacos.api.ai.model.a2a.AgentCardDetailInfo;
|
||||
import com.alibaba.nacos.api.ai.model.a2a.AgentEndpoint;
|
||||
@@ -39,7 +41,6 @@ import com.alibaba.nacos.api.ai.model.mcp.McpServerDetailInfo;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.McpToolSpecification;
|
||||
import com.alibaba.nacos.api.ai.model.prompt.Prompt;
|
||||
import com.alibaba.nacos.api.common.Constants;
|
||||
import com.alibaba.nacos.api.config.ConfigService;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.api.exception.api.NacosApiException;
|
||||
import com.alibaba.nacos.api.model.v2.ErrorCode;
|
||||
@@ -48,6 +49,7 @@ import com.alibaba.nacos.client.ai.cache.NacosAgentCardCacheHolder;
|
||||
import com.alibaba.nacos.client.ai.cache.NacosAgentSpecCacheHolder;
|
||||
import com.alibaba.nacos.client.ai.cache.NacosMcpServerCacheHolder;
|
||||
import com.alibaba.nacos.client.ai.cache.NacosPromptCacheHolder;
|
||||
import com.alibaba.nacos.client.ai.cache.NacosSkillCacheHolder;
|
||||
import com.alibaba.nacos.client.ai.event.AgentCardListenerInvoker;
|
||||
import com.alibaba.nacos.client.ai.event.AgentSpecChangedEvent;
|
||||
import com.alibaba.nacos.client.ai.event.AgentSpecListenerInvoker;
|
||||
@@ -56,10 +58,11 @@ import com.alibaba.nacos.client.ai.event.McpServerChangedEvent;
|
||||
import com.alibaba.nacos.client.ai.event.McpServerListenerInvoker;
|
||||
import com.alibaba.nacos.client.ai.event.PromptChangedEvent;
|
||||
import com.alibaba.nacos.client.ai.event.PromptListenerInvoker;
|
||||
import com.alibaba.nacos.client.ai.event.SkillChangedEvent;
|
||||
import com.alibaba.nacos.client.ai.event.SkillListenerInvoker;
|
||||
import com.alibaba.nacos.client.ai.remote.AiClientProxy;
|
||||
import com.alibaba.nacos.client.ai.remote.AiGrpcClient;
|
||||
import com.alibaba.nacos.client.ai.remote.AiHttpClientProxy;
|
||||
import com.alibaba.nacos.client.config.NacosConfigService;
|
||||
import com.alibaba.nacos.client.env.NacosClientProperties;
|
||||
import com.alibaba.nacos.client.utils.ClientBasicParamUtil;
|
||||
import com.alibaba.nacos.client.utils.LogUtils;
|
||||
@@ -103,9 +106,9 @@ public class NacosAiService implements AiService {
|
||||
|
||||
private final NacosAgentSpecCacheHolder agentSpecCacheHolder;
|
||||
|
||||
private final AiChangeNotifier aiChangeNotifier;
|
||||
private final NacosSkillCacheHolder skillCacheHolder;
|
||||
|
||||
private final ConfigService skillConfigService;
|
||||
private final AiChangeNotifier aiChangeNotifier;
|
||||
|
||||
public NacosAiService(Properties properties) throws NacosException {
|
||||
NacosClientProperties clientProperties = NacosClientProperties.PROTOTYPE.derive(properties);
|
||||
@@ -121,12 +124,12 @@ public class NacosAiService implements AiService {
|
||||
} else {
|
||||
this.aiClientProxy = this.grpcClient;
|
||||
}
|
||||
this.skillConfigService = new NacosConfigService(properties);
|
||||
this.mcpServerCacheHolder = new NacosMcpServerCacheHolder(grpcClient, clientProperties);
|
||||
this.agentCardCacheHolder = new NacosAgentCardCacheHolder(grpcClient, clientProperties);
|
||||
this.promptCacheHolder = new NacosPromptCacheHolder(this.aiClientProxy, clientProperties);
|
||||
this.agentSpecCacheHolder =
|
||||
new NacosAgentSpecCacheHolder(this.skillConfigService, this.namespaceId);
|
||||
new NacosAgentSpecCacheHolder(this.aiClientProxy, clientProperties);
|
||||
this.skillCacheHolder = new NacosSkillCacheHolder(this.aiClientProxy, clientProperties);
|
||||
this.aiChangeNotifier = new AiChangeNotifier();
|
||||
start();
|
||||
}
|
||||
@@ -144,6 +147,7 @@ public class NacosAiService implements AiService {
|
||||
NotifyCenter.registerToPublisher(McpServerChangedEvent.class, 16384);
|
||||
NotifyCenter.registerToPublisher(PromptChangedEvent.class, 16384);
|
||||
NotifyCenter.registerToPublisher(AgentSpecChangedEvent.class, 16384);
|
||||
NotifyCenter.registerToPublisher(SkillChangedEvent.class, 16384);
|
||||
NotifyCenter.registerSubscriber(this.aiChangeNotifier);
|
||||
}
|
||||
|
||||
@@ -448,6 +452,44 @@ public class NacosAiService implements AiService {
|
||||
return httpProxy.downloadSkillZip(skillName, null, label);
|
||||
}
|
||||
|
||||
@Override
|
||||
public byte[] subscribeSkill(String skillName, String version, String label,
|
||||
AbstractNacosSkillListener skillListener) throws NacosException {
|
||||
if (StringUtils.isBlank(skillName)) {
|
||||
throw new NacosApiException(NacosException.INVALID_PARAM, ErrorCode.PARAMETER_MISSING,
|
||||
"parameters `skillName` can't be empty or null");
|
||||
}
|
||||
if (null == skillListener) {
|
||||
throw new NacosApiException(NacosException.INVALID_PARAM, ErrorCode.PARAMETER_MISSING,
|
||||
"parameters `skillListener` can't be null");
|
||||
}
|
||||
|
||||
SkillListenerInvoker listenerInvoker = new SkillListenerInvoker(skillListener);
|
||||
aiChangeNotifier.registerListener(skillName, version, label, listenerInvoker);
|
||||
byte[] zipBytes = skillCacheHolder.subscribeSkill(skillName, version, label);
|
||||
if (null != zipBytes && !listenerInvoker.isInvoked()) {
|
||||
listenerInvoker.invoke(new NacosSkillEvent(skillName, zipBytes, null, null));
|
||||
}
|
||||
return zipBytes;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void unsubscribeSkill(String skillName, String version, String label,
|
||||
AbstractNacosSkillListener skillListener) throws NacosException {
|
||||
if (StringUtils.isBlank(skillName)) {
|
||||
throw new NacosApiException(NacosException.INVALID_PARAM, ErrorCode.PARAMETER_MISSING,
|
||||
"parameters `skillName` can't be empty or null");
|
||||
}
|
||||
if (null == skillListener) {
|
||||
return;
|
||||
}
|
||||
SkillListenerInvoker listenerInvoker = new SkillListenerInvoker(skillListener);
|
||||
aiChangeNotifier.deregisterListener(skillName, version, label, listenerInvoker);
|
||||
if (!aiChangeNotifier.isSkillSubscribed(skillName, version, label)) {
|
||||
skillCacheHolder.unsubscribeSkill(skillName, version, label);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== AgentSpec Methods ====================
|
||||
|
||||
@Override
|
||||
@@ -577,9 +619,9 @@ public class NacosAiService implements AiService {
|
||||
public void shutdown() throws NacosException {
|
||||
this.grpcClient.shutdown();
|
||||
this.httpProxy.shutdown();
|
||||
this.skillConfigService.shutDown();
|
||||
this.mcpServerCacheHolder.shutdown();
|
||||
this.promptCacheHolder.shutdown();
|
||||
this.agentSpecCacheHolder.shutdown();
|
||||
this.skillCacheHolder.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
+154
-314
@@ -16,40 +16,37 @@
|
||||
|
||||
package com.alibaba.nacos.client.ai.cache;
|
||||
|
||||
import com.alibaba.nacos.api.ai.constant.AiConstants;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpec;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpecResource;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpecUtils;
|
||||
import com.alibaba.nacos.api.config.ConfigService;
|
||||
import com.alibaba.nacos.api.config.listener.Listener;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.client.ai.event.AgentSpecChangedEvent;
|
||||
import com.alibaba.nacos.client.ai.remote.AgentSpecQueryResponse;
|
||||
import com.alibaba.nacos.client.ai.remote.AiClientProxy;
|
||||
import com.alibaba.nacos.client.ai.utils.CacheKeyUtils;
|
||||
import com.alibaba.nacos.client.env.NacosClientProperties;
|
||||
import com.alibaba.nacos.client.utils.LogUtils;
|
||||
import com.alibaba.nacos.common.executor.NameThreadFactory;
|
||||
import com.alibaba.nacos.common.lifecycle.Closeable;
|
||||
import com.alibaba.nacos.common.notify.NotifyCenter;
|
||||
import com.alibaba.nacos.common.utils.JacksonUtils;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.MapperFeature;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.json.JsonMapper;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Nacos AI module agent spec cache holder.
|
||||
*
|
||||
* <p>Reads agent spec data from Nacos Config via {@link ConfigService}, leveraging its local cache
|
||||
* and push notifications for real-time updates. The server writes a manifest config
|
||||
* ({@code agentspec_index.json}) at group {@code agentspec__{name}} containing the current online
|
||||
* version and file list. Each resource file is stored under group {@code agentspec__{name}__{version}}
|
||||
* with the file path as dataId.</p>
|
||||
* <p>Owns the per-subscription polling loop that periodically calls
|
||||
* {@link AiClientProxy#queryAgentSpec(String, String, String, String)} with the locally cached MD5
|
||||
* for conditional query. When the server returns 304 ({@link NacosException#NOT_MODIFIED})
|
||||
* the local cache is preserved and no callback fires; when the response carries new content
|
||||
* (different MD5) an {@link AgentSpecChangedEvent} is published so {@code AiChangeNotifier} can
|
||||
* dispatch it to all registered listeners.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
@@ -57,47 +54,69 @@ public class NacosAgentSpecCacheHolder implements Closeable {
|
||||
|
||||
private static final Logger LOGGER = LogUtils.logger(NacosAgentSpecCacheHolder.class);
|
||||
|
||||
private static final String MANIFEST_JSON_RESOURCE_NAME = "manifest.json";
|
||||
private final AiClientProxy aiClientProxy;
|
||||
|
||||
private static final long CONFIG_TIMEOUT = 3000L;
|
||||
|
||||
private final ConfigService configService;
|
||||
|
||||
private final String namespaceId;
|
||||
/**
|
||||
* agentSpecName -> last published MD5.
|
||||
*/
|
||||
private final Map<String, String> md5Cache;
|
||||
|
||||
/**
|
||||
* agentSpecName -> cached AgentSpec object.
|
||||
*/
|
||||
private final Map<String, AgentSpec> agentSpecCache;
|
||||
|
||||
private final Map<String, AgentSpecSubscriptionInfo> subscriptionMap;
|
||||
private final ScheduledExecutorService updaterExecutor;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
private final long updateIntervalMillis;
|
||||
|
||||
public NacosAgentSpecCacheHolder(ConfigService configService, String namespaceId) {
|
||||
this.configService = configService;
|
||||
this.namespaceId = namespaceId;
|
||||
private final Map<String, AgentSpecUpdater> updateTaskMap;
|
||||
|
||||
public NacosAgentSpecCacheHolder(AiClientProxy aiClientProxy,
|
||||
NacosClientProperties properties) {
|
||||
this.aiClientProxy = aiClientProxy;
|
||||
this.md5Cache = new ConcurrentHashMap<>(4);
|
||||
this.agentSpecCache = new ConcurrentHashMap<>(4);
|
||||
this.subscriptionMap = new ConcurrentHashMap<>(4);
|
||||
this.objectMapper =
|
||||
JsonMapper.builder().configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true)
|
||||
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).build()
|
||||
.setSerializationInclusion(JsonInclude.Include.NON_NULL);
|
||||
this.updateTaskMap = new ConcurrentHashMap<>(4);
|
||||
this.updaterExecutor = new ScheduledThreadPoolExecutor(1,
|
||||
new NameThreadFactory("com.alibaba.nacos.client.ai.agentspec.updater"));
|
||||
this.updateIntervalMillis = properties.getLong(
|
||||
AiConstants.AI_AGENTSPEC_CACHE_UPDATE_INTERVAL,
|
||||
AiConstants.DEFAULT_AI_CACHE_UPDATE_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load agent spec from Nacos Config (leverages ConfigService local cache).
|
||||
* Query agent spec synchronously (no subscription).
|
||||
*
|
||||
* @param agentSpecName name of agent spec
|
||||
* @return AgentSpec object, null if agent spec not found or manifest missing
|
||||
* @return AgentSpec object, null if not found
|
||||
* @throws NacosException if error occurs
|
||||
*/
|
||||
public AgentSpec queryAgentSpec(String agentSpecName) throws NacosException {
|
||||
return loadAgentSpecFromConfig(agentSpecName);
|
||||
if (StringUtils.isBlank(agentSpecName)) {
|
||||
throw new NacosException(NacosException.INVALID_PARAM,
|
||||
"Required parameter `agentSpecName` not present");
|
||||
}
|
||||
try {
|
||||
AgentSpecQueryResponse response =
|
||||
aiClientProxy.queryAgentSpec(agentSpecName, null, null, null);
|
||||
return response.getAgentSpec();
|
||||
} catch (NacosException e) {
|
||||
if (e.getErrCode() == NacosException.NOT_FOUND) {
|
||||
return null;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to agent spec changes via Nacos Config listeners.
|
||||
* Subscribe to agent spec changes and start polling.
|
||||
*
|
||||
* <p>Performs the initial query synchronously and primes the MD5 cache; subsequent polls
|
||||
* piggy-back the cached MD5 to short-circuit unchanged content.
|
||||
*
|
||||
* @param agentSpecName name of agent spec
|
||||
* @return current AgentSpec object, nullable if agent spec not found
|
||||
* @return current AgentSpec object, null if not found
|
||||
* @throws NacosException if error occurs
|
||||
*/
|
||||
public AgentSpec subscribeAgentSpec(String agentSpecName) throws NacosException {
|
||||
@@ -105,46 +124,31 @@ public class NacosAgentSpecCacheHolder implements Closeable {
|
||||
throw new NacosException(NacosException.INVALID_PARAM,
|
||||
"Required parameter `agentSpecName` not present");
|
||||
}
|
||||
String cacheKey = CacheKeyUtils.buildAgentSpecKey(agentSpecName);
|
||||
|
||||
if (subscriptionMap.containsKey(agentSpecName)) {
|
||||
return agentSpecCache.get(agentSpecName);
|
||||
AgentSpec agentSpec = null;
|
||||
try {
|
||||
AgentSpecQueryResponse response =
|
||||
aiClientProxy.queryAgentSpec(agentSpecName, null, null, null);
|
||||
agentSpec = response.getAgentSpec();
|
||||
// Only update cache during initial subscribe; do NOT publish event here.
|
||||
// The caller (NacosAiService) handles the first listener notification.
|
||||
String newMd5 = response.getMd5();
|
||||
if (StringUtils.isNotBlank(newMd5)) {
|
||||
md5Cache.put(cacheKey, newMd5);
|
||||
}
|
||||
} catch (NacosException e) {
|
||||
if (e.getErrCode() != NacosException.NOT_FOUND) {
|
||||
throw e;
|
||||
}
|
||||
md5Cache.remove(cacheKey);
|
||||
}
|
||||
|
||||
// Initial load
|
||||
AgentSpec agentSpec = loadAgentSpecFromConfig(agentSpecName);
|
||||
if (agentSpec != null) {
|
||||
agentSpecCache.put(agentSpecName, agentSpec);
|
||||
agentSpecCache.put(cacheKey, agentSpec);
|
||||
}
|
||||
|
||||
// Set up subscription
|
||||
AgentSpecSubscriptionInfo sub = new AgentSpecSubscriptionInfo(agentSpecName);
|
||||
subscriptionMap.put(agentSpecName, sub);
|
||||
|
||||
AgentSpecIndex index = loadAgentSpecIndex(agentSpecName);
|
||||
if (index != null && index.files != null) {
|
||||
sub.currentVersion = index.version;
|
||||
sub.currentFiles = index.files;
|
||||
subscribeResources(sub, index);
|
||||
}
|
||||
|
||||
// Listen to manifest for version changes
|
||||
Listener manifestListener = new Listener() {
|
||||
|
||||
@Override
|
||||
public Executor getExecutor() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receiveConfigInfo(String configInfo) {
|
||||
onManifestChanged(agentSpecName, configInfo);
|
||||
}
|
||||
};
|
||||
sub.manifestListener = manifestListener;
|
||||
configService.addListener(AgentSpecUtils.AGENTSPEC_INDEX_DATA_ID,
|
||||
AgentSpecUtils.buildAgentSpecGroup(agentSpecName), manifestListener);
|
||||
|
||||
LOGGER.info("Subscribed agent spec via config: {}", agentSpecName);
|
||||
addUpdateTask(agentSpecName);
|
||||
LOGGER.info("Subscribed agent spec: {}", agentSpecName);
|
||||
return agentSpec;
|
||||
}
|
||||
|
||||
@@ -157,259 +161,95 @@ public class NacosAgentSpecCacheHolder implements Closeable {
|
||||
if (StringUtils.isBlank(agentSpecName)) {
|
||||
return;
|
||||
}
|
||||
|
||||
AgentSpecSubscriptionInfo sub = subscriptionMap.remove(agentSpecName);
|
||||
if (sub != null) {
|
||||
if (sub.manifestListener != null) {
|
||||
configService.removeListener(AgentSpecUtils.AGENTSPEC_INDEX_DATA_ID,
|
||||
AgentSpecUtils.buildAgentSpecGroup(agentSpecName), sub.manifestListener);
|
||||
}
|
||||
unsubscribeResources(sub);
|
||||
}
|
||||
agentSpecCache.remove(agentSpecName);
|
||||
|
||||
String cacheKey = CacheKeyUtils.buildAgentSpecKey(agentSpecName);
|
||||
removeUpdateTask(agentSpecName);
|
||||
md5Cache.remove(cacheKey);
|
||||
agentSpecCache.remove(cacheKey);
|
||||
LOGGER.info("Unsubscribed agent spec: {}", agentSpecName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() throws NacosException {
|
||||
for (String agentSpecName : new java.util.HashSet<>(subscriptionMap.keySet())) {
|
||||
unsubscribeAgentSpec(agentSpecName);
|
||||
this.updaterExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
private void addUpdateTask(String agentSpecName) {
|
||||
String key = CacheKeyUtils.buildAgentSpecKey(agentSpecName);
|
||||
this.updateTaskMap.computeIfAbsent(key, s -> {
|
||||
AgentSpecUpdater task = new AgentSpecUpdater(agentSpecName);
|
||||
updaterExecutor.schedule(task, updateIntervalMillis, TimeUnit.MILLISECONDS);
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
private void removeUpdateTask(String agentSpecName) {
|
||||
String key = CacheKeyUtils.buildAgentSpecKey(agentSpecName);
|
||||
AgentSpecUpdater task = this.updateTaskMap.remove(key);
|
||||
if (task != null) {
|
||||
task.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== Private methods ========================
|
||||
private void processAgentSpec(String agentSpecName, String cacheKey,
|
||||
AgentSpecQueryResponse response) {
|
||||
String oldMd5 = md5Cache.get(cacheKey);
|
||||
String newMd5 = response == null ? null : response.getMd5();
|
||||
if (response == null) {
|
||||
md5Cache.remove(cacheKey);
|
||||
agentSpecCache.remove(cacheKey);
|
||||
} else if (StringUtils.isNotBlank(newMd5)) {
|
||||
md5Cache.put(cacheKey, newMd5);
|
||||
agentSpecCache.put(cacheKey, response.getAgentSpec());
|
||||
}
|
||||
if (response != null && !StringUtils.equals(oldMd5, newMd5)) {
|
||||
NotifyCenter.publishEvent(
|
||||
new AgentSpecChangedEvent(agentSpecName, response.getAgentSpec()));
|
||||
}
|
||||
}
|
||||
|
||||
private void onManifestChanged(String agentSpecName, String configInfo) {
|
||||
try {
|
||||
AgentSpecSubscriptionInfo sub = subscriptionMap.get(agentSpecName);
|
||||
if (sub == null) {
|
||||
private class AgentSpecUpdater implements Runnable {
|
||||
|
||||
private final String agentSpecName;
|
||||
|
||||
private final String cacheKey;
|
||||
|
||||
private final AtomicBoolean cancel = new AtomicBoolean(false);
|
||||
|
||||
AgentSpecUpdater(String agentSpecName) {
|
||||
this.agentSpecName = agentSpecName;
|
||||
this.cacheKey = CacheKeyUtils.buildAgentSpecKey(agentSpecName);
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
cancel.set(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
if (cancel.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
AgentSpecIndex newIndex = parseAgentSpecIndex(configInfo);
|
||||
String newVersion = newIndex != null ? newIndex.version : null;
|
||||
|
||||
if (!StringUtils.equals(sub.currentVersion, newVersion)) {
|
||||
LOGGER.info("AgentSpec {} manifest version changed: {} -> {}", agentSpecName,
|
||||
sub.currentVersion, newVersion);
|
||||
unsubscribeResources(sub);
|
||||
if (newIndex != null && newIndex.files != null) {
|
||||
sub.currentVersion = newIndex.version;
|
||||
sub.currentFiles = newIndex.files;
|
||||
subscribeResources(sub, newIndex);
|
||||
} else {
|
||||
sub.currentVersion = null;
|
||||
sub.currentFiles = null;
|
||||
}
|
||||
}
|
||||
|
||||
reloadAndPublish(agentSpecName);
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to handle manifest change for agent spec: {}", agentSpecName, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void onResourceChanged(String agentSpecName) {
|
||||
reloadAndPublish(agentSpecName);
|
||||
}
|
||||
|
||||
private void reloadAndPublish(String agentSpecName) {
|
||||
try {
|
||||
AgentSpec oldAgentSpec = agentSpecCache.get(agentSpecName);
|
||||
AgentSpec newAgentSpec = loadAgentSpecFromConfig(agentSpecName);
|
||||
|
||||
if (isAgentSpecChanged(oldAgentSpec, newAgentSpec)) {
|
||||
LOGGER.info("AgentSpec {} changed, publishing event.", agentSpecName);
|
||||
if (newAgentSpec != null) {
|
||||
agentSpecCache.put(agentSpecName, newAgentSpec);
|
||||
} else {
|
||||
agentSpecCache.remove(agentSpecName);
|
||||
}
|
||||
NotifyCenter.publishEvent(new AgentSpecChangedEvent(agentSpecName, newAgentSpec));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("Failed to reload agent spec: {}", agentSpecName, e);
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private AgentSpec loadAgentSpecFromConfig(String agentSpecName) throws NacosException {
|
||||
AgentSpecIndex index = loadAgentSpecIndex(agentSpecName);
|
||||
if (index == null || StringUtils.isBlank(index.version) || index.files == null
|
||||
|| index.files.isEmpty()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
String versionGroup =
|
||||
AgentSpecUtils.buildAgentSpecVersionGroup(agentSpecName, index.version);
|
||||
AgentSpec agentSpec = new AgentSpec();
|
||||
agentSpec.setNamespaceId(namespaceId);
|
||||
Map<String, AgentSpecResource> resourceMap = new HashMap<>(index.files.size());
|
||||
|
||||
for (String filePath : index.files) {
|
||||
String content = configService.getConfig(filePath, versionGroup, CONFIG_TIMEOUT);
|
||||
if (StringUtils.isBlank(content)) {
|
||||
continue;
|
||||
}
|
||||
AgentSpecResource resource = JacksonUtils.toObj(content, AgentSpecResource.class);
|
||||
if (resource == null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (MANIFEST_JSON_RESOURCE_NAME.equals(resource.getName())) {
|
||||
// Extract name and description from manifest.json content
|
||||
String manifestContent = resource.getContent();
|
||||
if (StringUtils.isNotBlank(manifestContent)) {
|
||||
try {
|
||||
Map<String, Object> manifestMap =
|
||||
JacksonUtils.toObj(manifestContent, Map.class);
|
||||
if (manifestMap != null) {
|
||||
Object nameObj = manifestMap.get("name");
|
||||
if (nameObj != null) {
|
||||
agentSpec.setName(String.valueOf(nameObj));
|
||||
}
|
||||
Object descObj = manifestMap.get("description");
|
||||
if (descObj != null) {
|
||||
agentSpec.setDescription(String.valueOf(descObj));
|
||||
}
|
||||
}
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to parse manifest.json content for agent spec: {}",
|
||||
agentSpecName, e);
|
||||
}
|
||||
}
|
||||
agentSpec.setContent(manifestContent);
|
||||
} else {
|
||||
String resourceId =
|
||||
AgentSpecUtils.generateResourceId(resource.getType(), resource.getName());
|
||||
resourceMap.put(resourceId, resource);
|
||||
}
|
||||
}
|
||||
|
||||
agentSpec.setResource(resourceMap);
|
||||
return agentSpec;
|
||||
}
|
||||
|
||||
private AgentSpecIndex loadAgentSpecIndex(String agentSpecName) throws NacosException {
|
||||
String group = AgentSpecUtils.buildAgentSpecGroup(agentSpecName);
|
||||
String indexContent = configService.getConfig(AgentSpecUtils.AGENTSPEC_INDEX_DATA_ID, group,
|
||||
CONFIG_TIMEOUT);
|
||||
return parseAgentSpecIndex(indexContent);
|
||||
}
|
||||
|
||||
private static AgentSpecIndex parseAgentSpecIndex(String json) {
|
||||
if (StringUtils.isBlank(json)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return JacksonUtils.toObj(json, AgentSpecIndex.class);
|
||||
} catch (Exception e) {
|
||||
LOGGER.warn("Failed to parse agent spec index: {}", e.getMessage());
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void subscribeResources(AgentSpecSubscriptionInfo sub, AgentSpecIndex index) {
|
||||
if (index.files == null || index.files.isEmpty() || StringUtils.isBlank(index.version)) {
|
||||
return;
|
||||
}
|
||||
String versionGroup =
|
||||
AgentSpecUtils.buildAgentSpecVersionGroup(sub.agentSpecName, index.version);
|
||||
sub.resourceGroup = versionGroup;
|
||||
for (String filePath : index.files) {
|
||||
Listener listener = new Listener() {
|
||||
|
||||
@Override
|
||||
public Executor getExecutor() {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void receiveConfigInfo(String configInfo) {
|
||||
onResourceChanged(sub.agentSpecName);
|
||||
}
|
||||
};
|
||||
try {
|
||||
configService.addListener(filePath, versionGroup, listener);
|
||||
sub.resourceListeners.put(filePath, listener);
|
||||
String currentMd5 = md5Cache.get(cacheKey);
|
||||
AgentSpecQueryResponse response =
|
||||
aiClientProxy.queryAgentSpec(agentSpecName, null, null, currentMd5);
|
||||
processAgentSpec(agentSpecName, cacheKey, response);
|
||||
} catch (NacosException e) {
|
||||
LOGGER.warn("Failed to add listener for {}:{}", versionGroup, filePath, e);
|
||||
if (e.getErrCode() == NacosException.NOT_FOUND) {
|
||||
processAgentSpec(agentSpecName, cacheKey, null);
|
||||
} else if (e.getErrCode() == NacosException.NOT_MODIFIED) {
|
||||
// No content change, keep local cache and skip callback.
|
||||
} else {
|
||||
LOGGER.warn(
|
||||
"AgentSpec updater query failed: name={}, err={}",
|
||||
agentSpecName, e.getErrMsg());
|
||||
}
|
||||
} finally {
|
||||
if (!cancel.get()) {
|
||||
updaterExecutor.schedule(this, updateIntervalMillis,
|
||||
TimeUnit.MILLISECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void unsubscribeResources(AgentSpecSubscriptionInfo sub) {
|
||||
if (StringUtils.isBlank(sub.resourceGroup)) {
|
||||
return;
|
||||
}
|
||||
for (Map.Entry<String, Listener> entry : sub.resourceListeners.entrySet()) {
|
||||
configService.removeListener(entry.getKey(), sub.resourceGroup, entry.getValue());
|
||||
}
|
||||
sub.resourceListeners.clear();
|
||||
sub.resourceGroup = null;
|
||||
}
|
||||
|
||||
private boolean isAgentSpecChanged(AgentSpec oldAgentSpec, AgentSpec newAgentSpec) {
|
||||
try {
|
||||
String newJson = objectMapper.writeValueAsString(newAgentSpec);
|
||||
if (null == oldAgentSpec) {
|
||||
LOGGER.info("Init new agent spec: {} -> {}",
|
||||
newAgentSpec != null ? newAgentSpec.getName() : "null", newJson);
|
||||
return true;
|
||||
}
|
||||
String oldJson = objectMapper.writeValueAsString(oldAgentSpec);
|
||||
if (!StringUtils.equals(oldJson, newJson)) {
|
||||
LOGGER.info("AgentSpec changed: {} -> {}", oldJson, newJson);
|
||||
return true;
|
||||
}
|
||||
} catch (JsonProcessingException e) {
|
||||
LOGGER.error("Compare agent spec info failed: ", e);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ======================== Inner classes ========================
|
||||
|
||||
private static class AgentSpecSubscriptionInfo {
|
||||
|
||||
final String agentSpecName;
|
||||
|
||||
String currentVersion;
|
||||
|
||||
List<String> currentFiles;
|
||||
|
||||
Listener manifestListener;
|
||||
|
||||
String resourceGroup;
|
||||
|
||||
final Map<String, Listener> resourceListeners = new ConcurrentHashMap<>(4);
|
||||
|
||||
AgentSpecSubscriptionInfo(String agentSpecName) {
|
||||
this.agentSpecName = agentSpecName;
|
||||
}
|
||||
}
|
||||
|
||||
private static class AgentSpecIndex {
|
||||
|
||||
private String version;
|
||||
|
||||
private List<String> files;
|
||||
|
||||
public String getVersion() {
|
||||
return version;
|
||||
}
|
||||
|
||||
public void setVersion(String version) {
|
||||
this.version = version;
|
||||
}
|
||||
|
||||
public List<String> getFiles() {
|
||||
return files;
|
||||
}
|
||||
|
||||
public void setFiles(List<String> files) {
|
||||
this.files = files;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+224
@@ -0,0 +1,224 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.client.ai.cache;
|
||||
|
||||
import com.alibaba.nacos.api.ai.constant.AiConstants;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.client.ai.event.SkillChangedEvent;
|
||||
import com.alibaba.nacos.client.ai.remote.AiClientProxy;
|
||||
import com.alibaba.nacos.client.ai.remote.SkillQueryResponse;
|
||||
import com.alibaba.nacos.client.ai.utils.CacheKeyUtils;
|
||||
import com.alibaba.nacos.client.env.NacosClientProperties;
|
||||
import com.alibaba.nacos.client.utils.LogUtils;
|
||||
import com.alibaba.nacos.common.executor.NameThreadFactory;
|
||||
import com.alibaba.nacos.common.lifecycle.Closeable;
|
||||
import com.alibaba.nacos.common.notify.NotifyCenter;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
import org.slf4j.Logger;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
import java.util.concurrent.ScheduledExecutorService;
|
||||
import java.util.concurrent.ScheduledThreadPoolExecutor;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/**
|
||||
* Nacos AI module skill cache holder.
|
||||
*
|
||||
* <p>Owns the per-subscription polling loop that periodically calls
|
||||
* {@link AiClientProxy#querySkill(String, String, String, String)} with the locally cached MD5
|
||||
* for conditional download. When the server returns 304 ({@link NacosException#NOT_MODIFIED})
|
||||
* the local cache is preserved and no callback fires; when the response carries new content
|
||||
* (different MD5) a {@link SkillChangedEvent} is published so {@code AiChangeNotifier} can
|
||||
* dispatch it to all registered listeners.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
public class NacosSkillCacheHolder implements Closeable {
|
||||
|
||||
private static final Logger LOGGER = LogUtils.logger(NacosSkillCacheHolder.class);
|
||||
|
||||
private final AiClientProxy aiClientProxy;
|
||||
|
||||
/**
|
||||
* cacheKey -> last published MD5 of the locally cached skill ZIP.
|
||||
*/
|
||||
private final Map<String, String> skillMd5Cache;
|
||||
|
||||
private final ScheduledExecutorService updaterExecutor;
|
||||
|
||||
private final long updateIntervalMillis;
|
||||
|
||||
private final Map<String, SkillUpdater> updateTaskMap;
|
||||
|
||||
public NacosSkillCacheHolder(AiClientProxy aiClientProxy, NacosClientProperties properties) {
|
||||
this.aiClientProxy = aiClientProxy;
|
||||
this.skillMd5Cache = new ConcurrentHashMap<>(4);
|
||||
this.updateTaskMap = new ConcurrentHashMap<>(4);
|
||||
this.updaterExecutor = new ScheduledThreadPoolExecutor(1,
|
||||
new NameThreadFactory("com.alibaba.nacos.client.ai.skill.updater"));
|
||||
this.updateIntervalMillis = properties.getLong(AiConstants.AI_SKILL_CACHE_UPDATE_INTERVAL,
|
||||
AiConstants.DEFAULT_AI_CACHE_UPDATE_INTERVAL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe skill and start polling for skill changes.
|
||||
*
|
||||
* <p>Performs the initial download synchronously and primes the MD5 cache; subsequent polls
|
||||
* piggy-back the cached MD5 to short-circuit unchanged content.
|
||||
*
|
||||
* @param skillName skill name
|
||||
* @param version skill version, optional
|
||||
* @param label skill label, optional
|
||||
* @return current skill ZIP bytes, never null when the server has the skill
|
||||
* @throws NacosException if error occurs
|
||||
*/
|
||||
public byte[] subscribeSkill(String skillName, String version, String label)
|
||||
throws NacosException {
|
||||
if (StringUtils.isBlank(skillName)) {
|
||||
throw new NacosException(NacosException.INVALID_PARAM,
|
||||
"Required parameter `skillName` not present");
|
||||
}
|
||||
String cacheKey = CacheKeyUtils.buildSkillKey(skillName, version, label);
|
||||
|
||||
byte[] zipBytes = null;
|
||||
try {
|
||||
SkillQueryResponse response = aiClientProxy.querySkill(skillName, version, label, null);
|
||||
zipBytes = response.getZipBytes();
|
||||
// Only update cache during initial subscribe; do NOT publish event here.
|
||||
// The caller (NacosAiService) handles the first listener notification to avoid
|
||||
// duplicate callbacks racing with the async NotifyCenter dispatch.
|
||||
String newMd5 = response.getMd5();
|
||||
if (StringUtils.isNotBlank(newMd5)) {
|
||||
skillMd5Cache.put(cacheKey, newMd5);
|
||||
}
|
||||
} catch (NacosException e) {
|
||||
if (e.getErrCode() != NacosException.NOT_FOUND) {
|
||||
throw e;
|
||||
}
|
||||
skillMd5Cache.remove(cacheKey);
|
||||
}
|
||||
addSkillUpdateTask(skillName, version, label);
|
||||
LOGGER.info("Subscribed skill: {}, version: {}, label: {}", skillName, version, label);
|
||||
return zipBytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe skill and remove update task.
|
||||
*
|
||||
* @param skillName skill name
|
||||
* @param version skill version, optional
|
||||
* @param label skill label, optional
|
||||
*/
|
||||
public void unsubscribeSkill(String skillName, String version, String label) {
|
||||
if (StringUtils.isBlank(skillName)) {
|
||||
return;
|
||||
}
|
||||
String cacheKey = CacheKeyUtils.buildSkillKey(skillName, version, label);
|
||||
|
||||
removeSkillUpdateTask(skillName, version, label);
|
||||
skillMd5Cache.remove(cacheKey);
|
||||
LOGGER.info("Unsubscribed skill: {}, version: {}, label: {}", skillName, version, label);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() throws NacosException {
|
||||
this.updaterExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
private void addSkillUpdateTask(String skillName, String version, String label) {
|
||||
String key = CacheKeyUtils.buildSkillKey(skillName, version, label);
|
||||
this.updateTaskMap.computeIfAbsent(key, s -> {
|
||||
SkillUpdater task = new SkillUpdater(skillName, version, label);
|
||||
updaterExecutor.schedule(task, updateIntervalMillis, TimeUnit.MILLISECONDS);
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
private void removeSkillUpdateTask(String skillName, String version, String label) {
|
||||
String key = CacheKeyUtils.buildSkillKey(skillName, version, label);
|
||||
SkillUpdater task = this.updateTaskMap.remove(key);
|
||||
if (task != null) {
|
||||
task.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private void processSkill(String skillName, String cacheKey, SkillQueryResponse response) {
|
||||
String oldMd5 = skillMd5Cache.get(cacheKey);
|
||||
String newMd5 = response == null ? null : response.getMd5();
|
||||
if (response == null) {
|
||||
skillMd5Cache.remove(cacheKey);
|
||||
} else if (StringUtils.isNotBlank(newMd5)) {
|
||||
skillMd5Cache.put(cacheKey, newMd5);
|
||||
}
|
||||
if (response != null && !StringUtils.equals(oldMd5, newMd5)) {
|
||||
NotifyCenter.publishEvent(new SkillChangedEvent(skillName, cacheKey,
|
||||
response.getZipBytes(), newMd5, response.getResolvedVersion()));
|
||||
}
|
||||
}
|
||||
|
||||
private class SkillUpdater implements Runnable {
|
||||
|
||||
private final String skillName;
|
||||
|
||||
private final String version;
|
||||
|
||||
private final String label;
|
||||
|
||||
private final String cacheKey;
|
||||
|
||||
private final AtomicBoolean cancel = new AtomicBoolean(false);
|
||||
|
||||
SkillUpdater(String skillName, String version, String label) {
|
||||
this.skillName = skillName;
|
||||
this.version = version;
|
||||
this.label = label;
|
||||
this.cacheKey = CacheKeyUtils.buildSkillKey(skillName, version, label);
|
||||
}
|
||||
|
||||
void cancel() {
|
||||
cancel.set(true);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void run() {
|
||||
if (cancel.get()) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
String currentMd5 = skillMd5Cache.get(cacheKey);
|
||||
SkillQueryResponse response = aiClientProxy.querySkill(skillName, version, label,
|
||||
currentMd5);
|
||||
processSkill(skillName, cacheKey, response);
|
||||
} catch (NacosException e) {
|
||||
if (e.getErrCode() == NacosException.NOT_FOUND) {
|
||||
processSkill(skillName, cacheKey, null);
|
||||
} else if (e.getErrCode() == NacosException.NOT_MODIFIED) {
|
||||
// No content change, keep local cache and skip callback.
|
||||
} else {
|
||||
LOGGER.warn("Skill updater execute query failed: skillName={}, err={}",
|
||||
skillName, e.getErrMsg());
|
||||
}
|
||||
} finally {
|
||||
if (!cancel.get()) {
|
||||
updaterExecutor.schedule(this, updateIntervalMillis, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import com.alibaba.nacos.api.ai.listener.NacosAgentCardEvent;
|
||||
import com.alibaba.nacos.api.ai.listener.NacosAgentSpecEvent;
|
||||
import com.alibaba.nacos.api.ai.listener.NacosMcpServerEvent;
|
||||
import com.alibaba.nacos.api.ai.listener.NacosPromptEvent;
|
||||
import com.alibaba.nacos.api.ai.listener.NacosSkillEvent;
|
||||
import com.alibaba.nacos.client.ai.utils.CacheKeyUtils;
|
||||
import com.alibaba.nacos.common.notify.Event;
|
||||
import com.alibaba.nacos.common.notify.listener.SmartSubscriber;
|
||||
@@ -47,11 +48,14 @@ public class AiChangeNotifier extends SmartSubscriber {
|
||||
|
||||
private final Map<String, Set<AgentSpecListenerInvoker>> agentSpecListenerInvokers;
|
||||
|
||||
private final Map<String, Set<SkillListenerInvoker>> skillListenerInvokers;
|
||||
|
||||
public AiChangeNotifier() {
|
||||
this.mcpServerListenerInvokers = new ConcurrentHashMap<>(2);
|
||||
this.agentCardListenerInvokers = new ConcurrentHashMap<>(2);
|
||||
this.promptListenerInvokers = new ConcurrentHashMap<>(2);
|
||||
this.agentSpecListenerInvokers = new ConcurrentHashMap<>(2);
|
||||
this.skillListenerInvokers = new ConcurrentHashMap<>(2);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -64,6 +68,8 @@ public class AiChangeNotifier extends SmartSubscriber {
|
||||
handlePromptChangedEvent((PromptChangedEvent) event);
|
||||
} else if (event instanceof AgentSpecChangedEvent) {
|
||||
handleAgentSpecChangedEvent((AgentSpecChangedEvent) event);
|
||||
} else if (event instanceof SkillChangedEvent) {
|
||||
handleSkillChangedEvent((SkillChangedEvent) event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,6 +121,18 @@ public class AiChangeNotifier extends SmartSubscriber {
|
||||
}
|
||||
}
|
||||
|
||||
private void handleSkillChangedEvent(SkillChangedEvent event) {
|
||||
String skillCacheKey = event.getCacheKey();
|
||||
if (!isSubscribed(skillCacheKey, skillListenerInvokers)) {
|
||||
return;
|
||||
}
|
||||
NacosSkillEvent notifiedEvent = new NacosSkillEvent(event.getSkillName(),
|
||||
event.getZipBytes(), event.getMd5(), event.getResolvedVersion());
|
||||
for (SkillListenerInvoker each : skillListenerInvokers.get(skillCacheKey)) {
|
||||
each.invoke(notifiedEvent);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Class<? extends Event>> subscribeTypes() {
|
||||
List<Class<? extends Event>> listenedEventTypes = new LinkedList<>();
|
||||
@@ -122,6 +140,7 @@ public class AiChangeNotifier extends SmartSubscriber {
|
||||
listenedEventTypes.add(AgentCardChangedEvent.class);
|
||||
listenedEventTypes.add(PromptChangedEvent.class);
|
||||
listenedEventTypes.add(AgentSpecChangedEvent.class);
|
||||
listenedEventTypes.add(SkillChangedEvent.class);
|
||||
return listenedEventTypes;
|
||||
}
|
||||
|
||||
@@ -210,6 +229,29 @@ public class AiChangeNotifier extends SmartSubscriber {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* register skill listener.
|
||||
*
|
||||
* @param skillName name of skill
|
||||
* @param version version of skill
|
||||
* @param label label of skill
|
||||
* @param listenerInvoker listener invoker
|
||||
*/
|
||||
public void registerListener(String skillName, String version, String label,
|
||||
SkillListenerInvoker listenerInvoker) {
|
||||
if (listenerInvoker == null) {
|
||||
return;
|
||||
}
|
||||
String key = CacheKeyUtils.buildSkillKey(skillName, version, label);
|
||||
skillListenerInvokers.compute(key, (k, skillListenerInvokers) -> {
|
||||
if (null == skillListenerInvokers) {
|
||||
skillListenerInvokers = new ConcurrentHashSet<>();
|
||||
}
|
||||
skillListenerInvokers.add(listenerInvoker);
|
||||
return skillListenerInvokers;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* deregister mcp server listener.
|
||||
*
|
||||
@@ -295,6 +337,29 @@ public class AiChangeNotifier extends SmartSubscriber {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* deregister skill listener.
|
||||
*
|
||||
* @param skillName name of skill
|
||||
* @param version version of skill
|
||||
* @param label label of skill
|
||||
* @param listenerInvoker listener invoker
|
||||
*/
|
||||
public void deregisterListener(String skillName, String version, String label,
|
||||
SkillListenerInvoker listenerInvoker) {
|
||||
if (listenerInvoker == null) {
|
||||
return;
|
||||
}
|
||||
String key = CacheKeyUtils.buildSkillKey(skillName, version, label);
|
||||
skillListenerInvokers.compute(key, (k, skillListenerInvokers) -> {
|
||||
if (null == skillListenerInvokers) {
|
||||
return null;
|
||||
}
|
||||
skillListenerInvokers.remove(listenerInvoker);
|
||||
return skillListenerInvokers.isEmpty() ? null : skillListenerInvokers;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* check agent spec is subscribed.
|
||||
*
|
||||
@@ -341,6 +406,19 @@ public class AiChangeNotifier extends SmartSubscriber {
|
||||
return isSubscribed(key, promptListenerInvokers);
|
||||
}
|
||||
|
||||
/**
|
||||
* check skill is subscribed.
|
||||
*
|
||||
* @param skillName name of skill
|
||||
* @param version version of skill
|
||||
* @param label label of skill
|
||||
* @return is skill subscribed
|
||||
*/
|
||||
public boolean isSkillSubscribed(String skillName, String version, String label) {
|
||||
String key = CacheKeyUtils.buildSkillKey(skillName, version, label);
|
||||
return isSubscribed(key, skillListenerInvokers);
|
||||
}
|
||||
|
||||
private <T extends AbstractAiListenerInvoker<?, ?>> boolean isSubscribed(String key,
|
||||
Map<String, Set<T>> listenerInvokers) {
|
||||
return CollectionUtils.isNotEmpty(listenerInvokers.get(key));
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.client.ai.event;
|
||||
|
||||
import com.alibaba.nacos.common.notify.Event;
|
||||
|
||||
/**
|
||||
* Skill changed event for internal notification.
|
||||
*
|
||||
* <p>Published by {@code NacosSkillCacheHolder} whenever the polling loop detects that the
|
||||
* server-side published skill content has changed (i.e. the response MD5 is different from the
|
||||
* locally cached MD5). {@code AiChangeNotifier} consumes the event and dispatches it to all
|
||||
* registered {@code AbstractNacosSkillListener}s for the same cache key.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
public class SkillChangedEvent extends Event {
|
||||
|
||||
private static final long serialVersionUID = 1L;
|
||||
|
||||
private final String skillName;
|
||||
|
||||
private final String cacheKey;
|
||||
|
||||
private final byte[] zipBytes;
|
||||
|
||||
private final String md5;
|
||||
|
||||
private final String resolvedVersion;
|
||||
|
||||
public SkillChangedEvent(String skillName, String cacheKey, byte[] zipBytes, String md5,
|
||||
String resolvedVersion) {
|
||||
this.skillName = skillName;
|
||||
this.cacheKey = cacheKey;
|
||||
this.zipBytes = zipBytes;
|
||||
this.md5 = md5;
|
||||
this.resolvedVersion = resolvedVersion;
|
||||
}
|
||||
|
||||
public String getSkillName() {
|
||||
return skillName;
|
||||
}
|
||||
|
||||
public String getCacheKey() {
|
||||
return cacheKey;
|
||||
}
|
||||
|
||||
public byte[] getZipBytes() {
|
||||
return zipBytes;
|
||||
}
|
||||
|
||||
public String getMd5() {
|
||||
return md5;
|
||||
}
|
||||
|
||||
public String getResolvedVersion() {
|
||||
return resolvedVersion;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.client.ai.event;
|
||||
|
||||
import com.alibaba.nacos.api.ai.listener.AbstractNacosSkillListener;
|
||||
import com.alibaba.nacos.api.ai.listener.NacosSkillEvent;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
|
||||
/**
|
||||
* Nacos AI module skill listener invoker.
|
||||
*
|
||||
* @author nacos
|
||||
*/
|
||||
public class SkillListenerInvoker
|
||||
extends AbstractAiListenerInvoker<NacosSkillEvent, AbstractNacosSkillListener> {
|
||||
|
||||
private static final Logger LOGGER = LoggerFactory.getLogger(SkillListenerInvoker.class);
|
||||
|
||||
public SkillListenerInvoker(AbstractNacosSkillListener listener) {
|
||||
super(listener);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void logInvoke(NacosSkillEvent event) {
|
||||
LOGGER.info("Invoke event skillName: {} to Listener: {}", event.getSkillName(),
|
||||
listener.toString());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.client.ai.remote;
|
||||
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpec;
|
||||
|
||||
/**
|
||||
* Response wrapper for the listener-style agentspec query. Carries the resolved AgentSpec together
|
||||
* with the listener-related response headers ({@code X-Nacos-AgentSpec-Md5} and
|
||||
* {@code X-Nacos-AgentSpec-Resolved-Version}) so the client cache can short-circuit the next poll
|
||||
* when the published content has not changed.
|
||||
*
|
||||
* @author nacos
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public class AgentSpecQueryResponse {
|
||||
|
||||
private final AgentSpec agentSpec;
|
||||
|
||||
private final String md5;
|
||||
|
||||
private final String resolvedVersion;
|
||||
|
||||
public AgentSpecQueryResponse(AgentSpec agentSpec, String md5, String resolvedVersion) {
|
||||
this.agentSpec = agentSpec;
|
||||
this.md5 = md5;
|
||||
this.resolvedVersion = resolvedVersion;
|
||||
}
|
||||
|
||||
public AgentSpec getAgentSpec() {
|
||||
return agentSpec;
|
||||
}
|
||||
|
||||
public String getMd5() {
|
||||
return md5;
|
||||
}
|
||||
|
||||
public String getResolvedVersion() {
|
||||
return resolvedVersion;
|
||||
}
|
||||
}
|
||||
@@ -41,4 +41,38 @@ public interface AiClientProxy extends Closeable {
|
||||
*/
|
||||
Prompt queryPrompt(String promptKey, String version, String label, String md5)
|
||||
throws NacosException;
|
||||
|
||||
/**
|
||||
* Query skill by latest/version/label with optional md5 for conditional download.
|
||||
*
|
||||
* <p>When {@code md5} matches the server-published content fingerprint, the implementation
|
||||
* MUST throw {@link NacosException} with code {@link NacosException#NOT_MODIFIED} so the
|
||||
* caller can keep its local cache.
|
||||
*
|
||||
* @param skillName skill name
|
||||
* @param version skill version, optional
|
||||
* @param label skill label, optional
|
||||
* @param md5 client md5 for conditional query, optional
|
||||
* @return skill ZIP bytes plus the published content MD5 and resolved version headers
|
||||
* @throws NacosException if request parameter is invalid or handle error
|
||||
*/
|
||||
SkillQueryResponse querySkill(String skillName, String version, String label, String md5)
|
||||
throws NacosException;
|
||||
|
||||
/**
|
||||
* Query agentspec by latest/version/label with optional md5 for conditional query.
|
||||
*
|
||||
* <p>When {@code md5} matches the server-published content fingerprint, the implementation
|
||||
* MUST throw {@link NacosException} with code {@link NacosException#NOT_MODIFIED} so the
|
||||
* caller can keep its local cache.
|
||||
*
|
||||
* @param agentSpecName agentspec name
|
||||
* @param version agentspec version, optional
|
||||
* @param label agentspec label, optional
|
||||
* @param md5 client md5 for conditional query, optional
|
||||
* @return agentspec plus the published content MD5 and resolved version headers
|
||||
* @throws NacosException if request parameter is invalid or handle error
|
||||
*/
|
||||
AgentSpecQueryResponse queryAgentSpec(String agentSpecName, String version, String label,
|
||||
String md5) throws NacosException;
|
||||
}
|
||||
|
||||
@@ -732,6 +732,20 @@ public class AiGrpcClient implements AiClientProxy {
|
||||
return builder.build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public SkillQueryResponse querySkill(String skillName, String version, String label, String md5)
|
||||
throws NacosException {
|
||||
throw new NacosException(NacosException.SERVER_NOT_IMPLEMENTED,
|
||||
"Skill query is only supported via HTTP transport.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentSpecQueryResponse queryAgentSpec(String agentSpecName, String version,
|
||||
String label, String md5) throws NacosException {
|
||||
throw new NacosException(NacosException.SERVER_NOT_IMPLEMENTED,
|
||||
"AgentSpec query is only supported via HTTP transport.");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() throws NacosException {
|
||||
rpcClient.shutdown();
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
|
||||
package com.alibaba.nacos.client.ai.remote;
|
||||
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpec;
|
||||
import com.alibaba.nacos.api.ai.model.prompt.Prompt;
|
||||
import com.alibaba.nacos.api.ai.model.skills.SkillUtils;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
@@ -71,6 +72,8 @@ public class AiHttpClientProxy implements AiClientProxy {
|
||||
|
||||
private static final String SKILL_DOWNLOAD_PATH = "/v3/client/ai/skills";
|
||||
|
||||
private static final String AGENTSPEC_CLIENT_PATH = "/v3/client/ai/agentspecs";
|
||||
|
||||
private static final int MAX_RETRY = 3;
|
||||
|
||||
private static final boolean ENABLE_HTTPS = Boolean.getBoolean(TlsSystemConfig.TLS_ENABLE);
|
||||
@@ -172,7 +175,76 @@ public class AiHttpClientProxy implements AiClientProxy {
|
||||
return zipBytes;
|
||||
}
|
||||
|
||||
// ===== Generic HTTP infrastructure =====
|
||||
@Override
|
||||
public SkillQueryResponse querySkill(String skillName, String version, String label, String md5)
|
||||
throws NacosException {
|
||||
Map<String, String> params = new HashMap<>(8);
|
||||
params.put("namespaceId", namespaceId);
|
||||
params.put("name", skillName);
|
||||
if (StringUtils.isNotBlank(version)) {
|
||||
params.put("version", version);
|
||||
}
|
||||
if (StringUtils.isNotBlank(label)) {
|
||||
params.put("label", label);
|
||||
}
|
||||
if (StringUtils.isNotBlank(md5)) {
|
||||
params.put("md5", md5);
|
||||
}
|
||||
|
||||
RequestResource resource = RequestResource.aiBuilder().setNamespace(namespaceId)
|
||||
.setGroup(com.alibaba.nacos.api.common.Constants.DEFAULT_GROUP)
|
||||
.setResource(null == skillName ? StringUtils.EMPTY : skillName).build();
|
||||
|
||||
HttpRestResult<byte[]> restResult = reqApiBytesWithHeader(SKILL_DOWNLOAD_PATH, params,
|
||||
resource);
|
||||
byte[] zipBytes = restResult.getData();
|
||||
SkillUtils.validateZipBytes(zipBytes);
|
||||
try {
|
||||
SkillUtils.validateZipEntryPaths(zipBytes);
|
||||
} catch (Exception e) {
|
||||
throw new NacosException(NacosException.SERVER_ERROR,
|
||||
"Downloaded ZIP contains unsafe entry paths: " + e.getMessage(), e);
|
||||
}
|
||||
String publishedMd5 = restResult.getHeader().getValue("X-Nacos-Skill-Md5");
|
||||
String resolvedVersion = restResult.getHeader()
|
||||
.getValue("X-Nacos-Skill-Resolved-Version");
|
||||
return new SkillQueryResponse(zipBytes, publishedMd5, resolvedVersion);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AgentSpecQueryResponse queryAgentSpec(String agentSpecName, String version,
|
||||
String label, String md5) throws NacosException {
|
||||
Map<String, String> params = new HashMap<>(8);
|
||||
params.put("namespaceId", namespaceId);
|
||||
params.put("name", agentSpecName);
|
||||
if (StringUtils.isNotBlank(version)) {
|
||||
params.put("version", version);
|
||||
}
|
||||
if (StringUtils.isNotBlank(label)) {
|
||||
params.put("label", label);
|
||||
}
|
||||
if (StringUtils.isNotBlank(md5)) {
|
||||
params.put("md5", md5);
|
||||
}
|
||||
|
||||
RequestResource resource = RequestResource.aiBuilder().setNamespace(namespaceId)
|
||||
.setGroup(com.alibaba.nacos.api.common.Constants.DEFAULT_GROUP)
|
||||
.setResource(
|
||||
null == agentSpecName ? StringUtils.EMPTY : agentSpecName)
|
||||
.build();
|
||||
|
||||
HttpRestResult<String> restResult = reqApiStringWithHeader(
|
||||
AGENTSPEC_CLIENT_PATH, params, resource);
|
||||
String responseBody = restResult.getData();
|
||||
Result<AgentSpec> result =
|
||||
JacksonUtils.toObj(responseBody, new TypeReference<Result<AgentSpec>>() {
|
||||
});
|
||||
String publishedMd5 = restResult.getHeader().getValue("X-Nacos-AgentSpec-Md5");
|
||||
String resolvedVersion = restResult.getHeader()
|
||||
.getValue("X-Nacos-AgentSpec-Resolved-Version");
|
||||
return new AgentSpecQueryResponse(result.getData(), publishedMd5,
|
||||
resolvedVersion);
|
||||
}
|
||||
|
||||
private String reqApi(String api, Map<String, String> params, RequestResource resource)
|
||||
throws NacosException {
|
||||
@@ -236,6 +308,40 @@ public class AiHttpClientProxy implements AiClientProxy {
|
||||
+ exception.getMessage());
|
||||
}
|
||||
|
||||
private HttpRestResult<byte[]> reqApiBytesWithHeader(String api, Map<String, String> params,
|
||||
RequestResource resource) throws NacosException {
|
||||
List<String> servers = serverListManager.getServerList();
|
||||
if (servers.isEmpty()) {
|
||||
throw new NacosException(NacosException.INVALID_PARAM, "no server available");
|
||||
}
|
||||
|
||||
NacosException exception = new NacosException();
|
||||
int index = ThreadLocalRandom.current().nextInt(servers.size());
|
||||
|
||||
for (int i = 0; i < Math.max(servers.size(), MAX_RETRY); i++) {
|
||||
String server = servers.get(index % servers.size());
|
||||
try {
|
||||
return callServerBytesWithHeader(api, params, server, resource);
|
||||
} catch (NacosException e) {
|
||||
if (NacosException.NOT_MODIFIED == e.getErrCode()) {
|
||||
throw e;
|
||||
}
|
||||
exception = e;
|
||||
if (LOGGER.isDebugEnabled()) {
|
||||
LOGGER.debug("Request {} to server {} failed.", api, server, e);
|
||||
}
|
||||
}
|
||||
index = (index + 1) % servers.size();
|
||||
}
|
||||
|
||||
LOGGER.error("Request: {} failed, servers: {}, code: {}, msg: {}", api, servers,
|
||||
exception.getErrCode(),
|
||||
exception.getErrMsg());
|
||||
throw new NacosException(exception.getErrCode(),
|
||||
"Failed to request API: " + api + " after all servers(" + servers + ") tried: "
|
||||
+ exception.getMessage());
|
||||
}
|
||||
|
||||
private String callServer(String api, Map<String, String> params, String server,
|
||||
RequestResource resource)
|
||||
throws NacosException {
|
||||
@@ -283,6 +389,46 @@ public class AiHttpClientProxy implements AiClientProxy {
|
||||
if (restResult.ok()) {
|
||||
return restResult.getData();
|
||||
}
|
||||
if (HttpURLConnection.HTTP_NOT_MODIFIED == restResult.getCode()) {
|
||||
throw new NacosException(NacosException.NOT_MODIFIED, "not modified");
|
||||
}
|
||||
if (HttpURLConnection.HTTP_FORBIDDEN == restResult.getCode()) {
|
||||
securityProxy.reLogin();
|
||||
}
|
||||
throw new NacosException(restResult.getCode(), restResult.getMessage());
|
||||
} catch (NacosException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("[AI-HTTP] Failed to request {}", url, e);
|
||||
throw new NacosException(NacosException.SERVER_ERROR, e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Variant of {@link #callServerBytes} that exposes the raw {@link HttpRestResult} so callers
|
||||
* can inspect response headers (e.g. {@code X-Nacos-Skill-Md5}). Status code translation rules
|
||||
* mirror {@link #callServerBytes}: 304 raises {@link NacosException#NOT_MODIFIED}, 403
|
||||
* triggers a security re-login before bubbling the original status code up.
|
||||
*/
|
||||
private HttpRestResult<byte[]> callServerBytesWithHeader(String api,
|
||||
Map<String, String> params, String server, RequestResource resource)
|
||||
throws NacosException {
|
||||
Map<String, String> securityHeaders = securityProxy.getIdentityContext(resource);
|
||||
Header header = Header.newInstance();
|
||||
header.addAll(securityHeaders);
|
||||
|
||||
String url = buildUrl(server, api);
|
||||
|
||||
try {
|
||||
HttpRestResult<byte[]> restResult = nacosRestTemplate.get(url, header,
|
||||
Query.newInstance().initParams(params), byte[].class);
|
||||
|
||||
if (restResult.ok()) {
|
||||
return restResult;
|
||||
}
|
||||
if (HttpURLConnection.HTTP_NOT_MODIFIED == restResult.getCode()) {
|
||||
throw new NacosException(NacosException.NOT_MODIFIED, "not modified");
|
||||
}
|
||||
if (HttpURLConnection.HTTP_FORBIDDEN == restResult.getCode()) {
|
||||
securityProxy.reLogin();
|
||||
}
|
||||
@@ -303,6 +449,74 @@ public class AiHttpClientProxy implements AiClientProxy {
|
||||
return serverAddr + ContextPathUtil.normalizeContextPath(contextPath) + relativePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request API returning String body with headers exposed, propagating 304 immediately.
|
||||
*/
|
||||
private HttpRestResult<String> reqApiStringWithHeader(String api,
|
||||
Map<String, String> params, RequestResource resource) throws NacosException {
|
||||
List<String> servers = serverListManager.getServerList();
|
||||
if (servers.isEmpty()) {
|
||||
throw new NacosException(NacosException.INVALID_PARAM, "no server available");
|
||||
}
|
||||
|
||||
NacosException exception = new NacosException();
|
||||
int index = ThreadLocalRandom.current().nextInt(servers.size());
|
||||
|
||||
for (int i = 0; i < Math.max(servers.size(), MAX_RETRY); i++) {
|
||||
String server = servers.get(index % servers.size());
|
||||
try {
|
||||
return callServerStringWithHeader(api, params, server, resource);
|
||||
} catch (NacosException e) {
|
||||
if (NacosException.NOT_MODIFIED == e.getErrCode()) {
|
||||
throw e;
|
||||
}
|
||||
exception = e;
|
||||
if (LOGGER.isDebugEnabled()) {
|
||||
LOGGER.debug("Request {} to server {} failed.", api, server, e);
|
||||
}
|
||||
}
|
||||
index = (index + 1) % servers.size();
|
||||
}
|
||||
|
||||
LOGGER.error("Request: {} failed, servers: {}, code: {}, msg: {}", api, servers,
|
||||
exception.getErrCode(),
|
||||
exception.getErrMsg());
|
||||
throw new NacosException(exception.getErrCode(),
|
||||
"Failed to request API: " + api + " after all servers(" + servers + ") tried: "
|
||||
+ exception.getMessage());
|
||||
}
|
||||
|
||||
private HttpRestResult<String> callServerStringWithHeader(String api,
|
||||
Map<String, String> params, String server, RequestResource resource)
|
||||
throws NacosException {
|
||||
Map<String, String> securityHeaders = securityProxy.getIdentityContext(resource);
|
||||
Header header = Header.newInstance();
|
||||
header.addAll(securityHeaders);
|
||||
|
||||
String url = buildUrl(server, api);
|
||||
|
||||
try {
|
||||
HttpRestResult<String> restResult = nacosRestTemplate.get(url, header,
|
||||
Query.newInstance().initParams(params), String.class);
|
||||
|
||||
if (restResult.ok()) {
|
||||
return restResult;
|
||||
}
|
||||
if (HttpURLConnection.HTTP_NOT_MODIFIED == restResult.getCode()) {
|
||||
throw new NacosException(NacosException.NOT_MODIFIED, "not modified");
|
||||
}
|
||||
if (HttpURLConnection.HTTP_FORBIDDEN == restResult.getCode()) {
|
||||
securityProxy.reLogin();
|
||||
}
|
||||
throw new NacosException(restResult.getCode(), restResult.getMessage());
|
||||
} catch (NacosException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
LOGGER.error("[AI-HTTP] Failed to request {}", url, e);
|
||||
throw new NacosException(NacosException.SERVER_ERROR, e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void shutdown() throws NacosException {
|
||||
serverListManager.shutdown();
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.client.ai.remote;
|
||||
|
||||
/**
|
||||
* Response wrapper for the listener-style skill query. Carries the freshly downloaded ZIP bytes
|
||||
* together with the listener-related response headers ({@code X-Nacos-Skill-Md5} and
|
||||
* {@code X-Nacos-Skill-Resolved-Version}) so the client cache can short-circuit the next poll
|
||||
* when the published content has not changed.
|
||||
*
|
||||
* @author nacos
|
||||
* @since 3.2.0
|
||||
*/
|
||||
public class SkillQueryResponse {
|
||||
|
||||
private final byte[] zipBytes;
|
||||
|
||||
private final String md5;
|
||||
|
||||
private final String resolvedVersion;
|
||||
|
||||
public SkillQueryResponse(byte[] zipBytes, String md5, String resolvedVersion) {
|
||||
this.zipBytes = zipBytes;
|
||||
this.md5 = md5;
|
||||
this.resolvedVersion = resolvedVersion;
|
||||
}
|
||||
|
||||
public byte[] getZipBytes() {
|
||||
return zipBytes;
|
||||
}
|
||||
|
||||
public String getMd5() {
|
||||
return md5;
|
||||
}
|
||||
|
||||
public String getResolvedVersion() {
|
||||
return resolvedVersion;
|
||||
}
|
||||
}
|
||||
@@ -59,6 +59,24 @@ public class CacheKeyUtils {
|
||||
return skillName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build skill query key.
|
||||
*
|
||||
* @param skillName skill name
|
||||
* @param version skill version, optional
|
||||
* @param label skill label, optional
|
||||
* @return skill query key, pattern ${skillName}::label:${label}|version:${version}|latest
|
||||
*/
|
||||
public static String buildSkillKey(String skillName, String version, String label) {
|
||||
if (StringUtils.isNotBlank(label)) {
|
||||
return skillName + "::label:" + label;
|
||||
}
|
||||
if (StringUtils.isNotBlank(version)) {
|
||||
return skillName + "::version:" + version;
|
||||
}
|
||||
return skillName + "::" + LATEST_VERSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build agent spec key.
|
||||
*
|
||||
|
||||
@@ -35,7 +35,6 @@ import com.alibaba.nacos.api.ai.model.mcp.McpServerDetailInfo;
|
||||
import com.alibaba.nacos.api.ai.model.mcp.registry.ServerVersionDetail;
|
||||
import com.alibaba.nacos.api.ai.model.prompt.Prompt;
|
||||
import com.alibaba.nacos.api.common.Constants;
|
||||
import com.alibaba.nacos.api.config.ConfigService;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.api.exception.api.NacosApiException;
|
||||
import com.alibaba.nacos.client.ai.cache.NacosAgentCardCacheHolder;
|
||||
@@ -101,9 +100,6 @@ class NacosAiServiceTest {
|
||||
@Mock
|
||||
private AiClientProxy aiClientProxy;
|
||||
|
||||
@Mock
|
||||
private ConfigService skillConfigService;
|
||||
|
||||
@Mock
|
||||
private AiChangeNotifier aiChangeNotifier;
|
||||
|
||||
@@ -819,7 +815,6 @@ class NacosAiServiceTest {
|
||||
nacosAiService.shutdown();
|
||||
verify(grpcClient).shutdown();
|
||||
verify(httpProxy).shutdown();
|
||||
verify(skillConfigService).shutDown();
|
||||
verify(mcpServerCacheHolder).shutdown();
|
||||
verify(promptCacheHolder).shutdown();
|
||||
verify(agentSpecCacheHolder).shutdown();
|
||||
@@ -883,10 +878,6 @@ class NacosAiServiceTest {
|
||||
NacosAgentSpecCacheHolder autoBuildAgentSpecCacheHolder =
|
||||
(NacosAgentSpecCacheHolder) field.get(nacosAiService);
|
||||
field.set(nacosAiService, agentSpecCacheHolder);
|
||||
field = NacosAiService.class.getDeclaredField("skillConfigService");
|
||||
field.setAccessible(true);
|
||||
ConfigService autoBuildConfigService = (ConfigService) field.get(nacosAiService);
|
||||
field.set(nacosAiService, skillConfigService);
|
||||
field = NacosAiService.class.getDeclaredField("aiChangeNotifier");
|
||||
field.setAccessible(true);
|
||||
field.set(nacosAiService, aiChangeNotifier);
|
||||
@@ -897,7 +888,6 @@ class NacosAiServiceTest {
|
||||
autoBuildAgentCacheHolder.shutdown();
|
||||
autoBuildPromptCacheHolder.shutdown();
|
||||
autoBuildAgentSpecCacheHolder.shutdown();
|
||||
autoBuildConfigService.shutDown();
|
||||
} catch (NacosException ignored) {
|
||||
}
|
||||
}
|
||||
|
||||
+245
-425
@@ -16,473 +16,293 @@
|
||||
|
||||
package com.alibaba.nacos.client.ai.cache;
|
||||
|
||||
import com.alibaba.nacos.api.ai.constant.AiConstants;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpec;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpecUtils;
|
||||
import com.alibaba.nacos.api.config.ConfigService;
|
||||
import com.alibaba.nacos.api.config.listener.Listener;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.client.ai.event.AgentSpecChangedEvent;
|
||||
import com.alibaba.nacos.client.ai.remote.AgentSpecQueryResponse;
|
||||
import com.alibaba.nacos.client.ai.remote.AiClientProxy;
|
||||
import com.alibaba.nacos.client.env.NacosClientProperties;
|
||||
import com.alibaba.nacos.common.notify.Event;
|
||||
import com.alibaba.nacos.common.notify.NotifyCenter;
|
||||
import com.alibaba.nacos.common.notify.listener.Subscriber;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.mockito.junit.jupiter.MockitoSettings;
|
||||
import org.mockito.quality.Strictness;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
@MockitoSettings(strictness = Strictness.LENIENT)
|
||||
class NacosAgentSpecCacheHolderTest {
|
||||
|
||||
private static final String SPEC_NAME = "test-agent";
|
||||
|
||||
private static final String VERSION = "v1";
|
||||
|
||||
private static final String NAMESPACE = "ns";
|
||||
|
||||
@Mock
|
||||
private ConfigService configService;
|
||||
private AiClientProxy aiClientProxy;
|
||||
|
||||
private NacosAgentSpecCacheHolder cacheHolder;
|
||||
|
||||
private final List<MockAgentSpecEventSubscriber> registeredSubscribers = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
cacheHolder = new NacosAgentSpecCacheHolder(configService, NAMESPACE);
|
||||
Properties properties = new Properties();
|
||||
properties.put(AiConstants.AI_AGENTSPEC_CACHE_UPDATE_INTERVAL, "100");
|
||||
NotifyCenter.registerToPublisher(AgentSpecChangedEvent.class, 16384);
|
||||
cacheHolder = new NacosAgentSpecCacheHolder(aiClientProxy,
|
||||
NacosClientProperties.PROTOTYPE.derive(properties));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws Exception {
|
||||
void tearDown() throws NacosException {
|
||||
for (MockAgentSpecEventSubscriber each : registeredSubscribers) {
|
||||
NotifyCenter.deregisterSubscriber(each);
|
||||
}
|
||||
registeredSubscribers.clear();
|
||||
cacheHolder.shutdown();
|
||||
NotifyCenter.deregisterPublisher(AgentSpecChangedEvent.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryAgentSpecShouldReturnAgentSpec() throws Exception {
|
||||
AgentSpec spec = new AgentSpec();
|
||||
spec.setName(SPEC_NAME);
|
||||
AgentSpecQueryResponse response = new AgentSpecQueryResponse(spec, "md5a", "1.0.0");
|
||||
when(aiClientProxy.queryAgentSpec(SPEC_NAME, null, null, null))
|
||||
.thenReturn(response);
|
||||
|
||||
AgentSpec result = cacheHolder.queryAgentSpec(SPEC_NAME);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals(SPEC_NAME, result.getName());
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryAgentSpecShouldReturnNullWhenNotFound() throws Exception {
|
||||
when(aiClientProxy.queryAgentSpec(SPEC_NAME, null, null, null))
|
||||
.thenThrow(new NacosException(NacosException.NOT_FOUND, "not found"));
|
||||
|
||||
AgentSpec result = cacheHolder.queryAgentSpec(SPEC_NAME);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
void queryAgentSpecShouldThrowWhenBlankName() {
|
||||
assertThrows(NacosException.class, () -> cacheHolder.queryAgentSpec(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribeAgentSpecShouldReturnNullAndScheduleWhenNotFound() throws Exception {
|
||||
when(aiClientProxy.queryAgentSpec(SPEC_NAME, null, null, null))
|
||||
.thenThrow(new NacosException(NacosException.NOT_FOUND, "not found"));
|
||||
|
||||
AgentSpec result = cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
|
||||
assertNull(result);
|
||||
assertEquals(1, getUpdateTaskMap().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribeAgentSpecShouldCacheButNotPublishEvent() throws Exception {
|
||||
AgentSpec spec = new AgentSpec();
|
||||
spec.setName(SPEC_NAME);
|
||||
AgentSpecQueryResponse response = new AgentSpecQueryResponse(spec, "md5a", "1.0.0");
|
||||
when(aiClientProxy.queryAgentSpec(SPEC_NAME, null, null, null))
|
||||
.thenReturn(response);
|
||||
MockAgentSpecEventSubscriber subscriber = registerMockSubscriber();
|
||||
|
||||
AgentSpec result = cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
|
||||
assertNotNull(result);
|
||||
assertEquals("md5a", getMd5Cache().get(SPEC_NAME));
|
||||
// Initial subscribe must NOT publish event; the caller (NacosAiService)
|
||||
// is responsible for the first listener notification to avoid double-invocation.
|
||||
assertFalse(subscriber.await(200),
|
||||
"Initial subscribe should not publish event via NotifyCenter");
|
||||
assertFalse(subscriber.invokedMark.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribeAgentSpecShouldThrowWhenBlankName() {
|
||||
assertThrows(NacosException.class, () -> cacheHolder.subscribeAgentSpec(""));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updaterShouldIgnoreWhenNotModified() throws Exception {
|
||||
AgentSpec spec = new AgentSpec();
|
||||
spec.setName(SPEC_NAME);
|
||||
AgentSpecQueryResponse response = new AgentSpecQueryResponse(spec, "md5a", "1.0.0");
|
||||
when(aiClientProxy.queryAgentSpec(SPEC_NAME, null, null, null))
|
||||
.thenReturn(response);
|
||||
when(aiClientProxy.queryAgentSpec(SPEC_NAME, null, null, "md5a"))
|
||||
.thenThrow(new NacosException(NacosException.NOT_MODIFIED, "up to date"));
|
||||
cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
MockAgentSpecEventSubscriber subscriber = registerMockSubscriber();
|
||||
|
||||
Runnable updater = getOnlyUpdater();
|
||||
updater.run();
|
||||
|
||||
assertEquals("md5a", getMd5Cache().get(SPEC_NAME));
|
||||
assertFalse(subscriber.await(200));
|
||||
assertFalse(subscriber.invokedMark.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void updaterShouldEvictCacheWhenNotFound() throws Exception {
|
||||
AgentSpec spec = new AgentSpec();
|
||||
spec.setName(SPEC_NAME);
|
||||
AgentSpecQueryResponse response = new AgentSpecQueryResponse(spec, "md5a", "1.0.0");
|
||||
when(aiClientProxy.queryAgentSpec(SPEC_NAME, null, null, null))
|
||||
.thenReturn(response);
|
||||
when(aiClientProxy.queryAgentSpec(SPEC_NAME, null, null, "md5a"))
|
||||
.thenThrow(new NacosException(NacosException.NOT_FOUND, "not found"));
|
||||
cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
MockAgentSpecEventSubscriber subscriber = registerMockSubscriber();
|
||||
|
||||
Runnable updater = getOnlyUpdater();
|
||||
updater.run();
|
||||
|
||||
assertNull(getMd5Cache().get(SPEC_NAME));
|
||||
assertFalse(subscriber.await(200));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updaterShouldPublishEventWhenMd5Changed() throws Exception {
|
||||
AgentSpec spec1 = new AgentSpec();
|
||||
spec1.setName(SPEC_NAME);
|
||||
AgentSpec spec2 = new AgentSpec();
|
||||
spec2.setName(SPEC_NAME);
|
||||
spec2.setDescription("updated");
|
||||
AgentSpecQueryResponse first = new AgentSpecQueryResponse(spec1, "md5a", "1.0.0");
|
||||
AgentSpecQueryResponse second = new AgentSpecQueryResponse(spec2, "md5b", "1.0.1");
|
||||
when(aiClientProxy.queryAgentSpec(SPEC_NAME, null, null, null))
|
||||
.thenReturn(first);
|
||||
when(aiClientProxy.queryAgentSpec(SPEC_NAME, null, null, "md5a"))
|
||||
.thenReturn(second);
|
||||
cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
MockAgentSpecEventSubscriber subscriber = registerMockSubscriber();
|
||||
|
||||
Runnable updater = getOnlyUpdater();
|
||||
updater.run();
|
||||
|
||||
assertEquals("md5b", getMd5Cache().get(SPEC_NAME));
|
||||
assertTrue(subscriber.await(5000));
|
||||
assertTrue(subscriber.invokedMark.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsubscribeAgentSpecShouldCancelTaskAndRemoveCache() throws Exception {
|
||||
AgentSpec spec = new AgentSpec();
|
||||
spec.setName(SPEC_NAME);
|
||||
AgentSpecQueryResponse response = new AgentSpecQueryResponse(spec, "md5a", "1.0.0");
|
||||
when(aiClientProxy.queryAgentSpec(SPEC_NAME, null, null, null))
|
||||
.thenReturn(response);
|
||||
cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
|
||||
cacheHolder.unsubscribeAgentSpec(SPEC_NAME);
|
||||
|
||||
assertTrue(getUpdateTaskMap().isEmpty());
|
||||
assertNull(getMd5Cache().get(SPEC_NAME));
|
||||
verify(aiClientProxy, never()).queryAgentSpec(SPEC_NAME, null, null, "md5a");
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribeAgentSpecShouldThrowOnUnexpectedException() throws Exception {
|
||||
when(aiClientProxy.queryAgentSpec(SPEC_NAME, null, null, null))
|
||||
.thenThrow(new NacosException(NacosException.SERVER_ERROR, "server error"));
|
||||
|
||||
assertThrows(NacosException.class, () -> cacheHolder.subscribeAgentSpec(SPEC_NAME));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updaterShouldIgnoreGeneralExceptionAndKeepCache() throws Exception {
|
||||
AgentSpec spec = new AgentSpec();
|
||||
spec.setName(SPEC_NAME);
|
||||
AgentSpecQueryResponse response = new AgentSpecQueryResponse(spec, "md5a", "1.0.0");
|
||||
when(aiClientProxy.queryAgentSpec(SPEC_NAME, null, null, null))
|
||||
.thenReturn(response);
|
||||
when(aiClientProxy.queryAgentSpec(SPEC_NAME, null, null, "md5a"))
|
||||
.thenThrow(new NacosException(NacosException.SERVER_ERROR, "server error"));
|
||||
cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
|
||||
Runnable updater = getOnlyUpdater();
|
||||
updater.run();
|
||||
|
||||
assertNotNull(getMd5Cache().get(SPEC_NAME));
|
||||
assertEquals(1, getUpdateTaskMap().size());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private static <T> T readField(Object target, String name) throws Exception {
|
||||
Class<?> c = target.getClass();
|
||||
while (c != null) {
|
||||
for (Field f : c.getDeclaredFields()) {
|
||||
if (f.getName().equals(name)) {
|
||||
f.setAccessible(true);
|
||||
return (T) f.get(target);
|
||||
}
|
||||
}
|
||||
c = c.getSuperclass();
|
||||
private Map<String, String> getMd5Cache() throws Exception {
|
||||
Field field = NacosAgentSpecCacheHolder.class.getDeclaredField("md5Cache");
|
||||
field.setAccessible(true);
|
||||
return (Map<String, String>) field.get(cacheHolder);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> getUpdateTaskMap() throws Exception {
|
||||
Field field = NacosAgentSpecCacheHolder.class.getDeclaredField("updateTaskMap");
|
||||
field.setAccessible(true);
|
||||
return (Map<String, Object>) field.get(cacheHolder);
|
||||
}
|
||||
|
||||
private Runnable getOnlyUpdater() throws Exception {
|
||||
Object updater = getUpdateTaskMap().values().iterator().next();
|
||||
return (Runnable) updater;
|
||||
}
|
||||
|
||||
private MockAgentSpecEventSubscriber registerMockSubscriber() {
|
||||
MockAgentSpecEventSubscriber subscriber = new MockAgentSpecEventSubscriber();
|
||||
NotifyCenter.registerSubscriber(subscriber);
|
||||
registeredSubscribers.add(subscriber);
|
||||
return subscriber;
|
||||
}
|
||||
|
||||
private static class MockAgentSpecEventSubscriber
|
||||
extends Subscriber<AgentSpecChangedEvent> {
|
||||
|
||||
private final AtomicBoolean invokedMark = new AtomicBoolean(false);
|
||||
private volatile CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
@Override
|
||||
public void onEvent(AgentSpecChangedEvent event) {
|
||||
invokedMark.set(true);
|
||||
latch.countDown();
|
||||
}
|
||||
throw new NoSuchFieldException(name);
|
||||
}
|
||||
|
||||
private void mockIndex(String agentSpecName, String json) throws NacosException {
|
||||
String group = AgentSpecUtils.buildAgentSpecGroup(agentSpecName);
|
||||
when(configService.getConfig(eq(AgentSpecUtils.AGENTSPEC_INDEX_DATA_ID), eq(group),
|
||||
anyLong())).thenReturn(json);
|
||||
}
|
||||
|
||||
private void mockResource(String agentSpecName, String version, String filePath,
|
||||
String content) throws NacosException {
|
||||
String versionGroup = AgentSpecUtils.buildAgentSpecVersionGroup(agentSpecName, version);
|
||||
when(configService.getConfig(eq(filePath), eq(versionGroup), anyLong()))
|
||||
.thenReturn(content);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testQueryAgentSpecReturnsNullWhenIndexBlank() throws NacosException {
|
||||
mockIndex(SPEC_NAME, null);
|
||||
assertNull(cacheHolder.queryAgentSpec(SPEC_NAME));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testQueryAgentSpecReturnsNullWhenIndexInvalidJson() throws NacosException {
|
||||
mockIndex(SPEC_NAME, "not-json{");
|
||||
assertNull(cacheHolder.queryAgentSpec(SPEC_NAME));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testQueryAgentSpecReturnsNullWhenIndexHasNoFiles() throws NacosException {
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"v1\",\"files\":[]}");
|
||||
assertNull(cacheHolder.queryAgentSpec(SPEC_NAME));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testQueryAgentSpecReturnsNullWhenIndexBlankVersion() throws NacosException {
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"\",\"files\":[\"a\"]}");
|
||||
assertNull(cacheHolder.queryAgentSpec(SPEC_NAME));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testQueryAgentSpecBuildsAgentSpecFromManifest() throws NacosException {
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"v1\",\"files\":[\"manifest.json\"]}");
|
||||
mockResource(SPEC_NAME, VERSION, "manifest.json",
|
||||
"{\"name\":\"manifest.json\",\"content\":\""
|
||||
+ "{\\\"name\\\":\\\"my-agent\\\",\\\"description\\\":\\\"d\\\"}\"}");
|
||||
AgentSpec spec = cacheHolder.queryAgentSpec(SPEC_NAME);
|
||||
assertNotNull(spec);
|
||||
assertEquals(NAMESPACE, spec.getNamespaceId());
|
||||
assertEquals("my-agent", spec.getName());
|
||||
assertEquals("d", spec.getDescription());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testQueryAgentSpecSkipsBlankResourceContent() throws NacosException {
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"v1\",\"files\":[\"manifest.json\",\"f.json\"]}");
|
||||
mockResource(SPEC_NAME, VERSION, "manifest.json",
|
||||
"{\"name\":\"manifest.json\",\"content\":\"{}\"}");
|
||||
mockResource(SPEC_NAME, VERSION, "f.json", "");
|
||||
AgentSpec spec = cacheHolder.queryAgentSpec(SPEC_NAME);
|
||||
assertNotNull(spec);
|
||||
assertEquals(0, spec.getResource().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testQueryAgentSpecSkipsBlankResourceContent2() throws NacosException {
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"v1\",\"files\":[\"f.json\"]}");
|
||||
mockResource(SPEC_NAME, VERSION, "f.json", " ");
|
||||
AgentSpec spec = cacheHolder.queryAgentSpec(SPEC_NAME);
|
||||
assertNotNull(spec);
|
||||
assertEquals(0, spec.getResource().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testQueryAgentSpecAddsNonManifestResource() throws NacosException {
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"v1\",\"files\":[\"r1.json\"]}");
|
||||
mockResource(SPEC_NAME, VERSION, "r1.json",
|
||||
"{\"name\":\"toolA.txt\",\"type\":\"tool\",\"content\":\"x\"}");
|
||||
AgentSpec spec = cacheHolder.queryAgentSpec(SPEC_NAME);
|
||||
assertNotNull(spec);
|
||||
assertEquals(1, spec.getResource().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testQueryAgentSpecManifestWithMalformedContent() throws NacosException {
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"v1\",\"files\":[\"manifest.json\"]}");
|
||||
// content is non-blank but not parseable as Map → caught and warned
|
||||
mockResource(SPEC_NAME, VERSION, "manifest.json",
|
||||
"{\"name\":\"manifest.json\",\"content\":\"not-json{\"}");
|
||||
AgentSpec spec = cacheHolder.queryAgentSpec(SPEC_NAME);
|
||||
assertNotNull(spec);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSubscribeAgentSpecBlankNameThrows() {
|
||||
NacosException ex = assertThrows(NacosException.class,
|
||||
() -> cacheHolder.subscribeAgentSpec(""));
|
||||
assertEquals(NacosException.INVALID_PARAM, ex.getErrCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSubscribeAgentSpecRegistersListenerAndCachesSpec() throws Exception {
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"v1\",\"files\":[\"manifest.json\"]}");
|
||||
mockResource(SPEC_NAME, VERSION, "manifest.json",
|
||||
"{\"name\":\"manifest.json\",\"content\":\"{}\"}");
|
||||
AgentSpec spec = cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
assertNotNull(spec);
|
||||
// manifest listener registered
|
||||
verify(configService, times(1)).addListener(eq(AgentSpecUtils.AGENTSPEC_INDEX_DATA_ID),
|
||||
eq(AgentSpecUtils.buildAgentSpecGroup(SPEC_NAME)), any(Listener.class));
|
||||
// resource listener registered
|
||||
verify(configService, times(1)).addListener(eq("manifest.json"),
|
||||
eq(AgentSpecUtils.buildAgentSpecVersionGroup(SPEC_NAME, VERSION)), any(Listener.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSubscribeAgentSpecIdempotent() throws Exception {
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"v1\",\"files\":[\"f.json\"]}");
|
||||
mockResource(SPEC_NAME, VERSION, "f.json",
|
||||
"{\"name\":\"r1\",\"type\":\"tool\",\"content\":\"x\"}");
|
||||
cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
// Second call should hit subscriptionMap.containsKey and return cached spec
|
||||
cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
verify(configService, times(1)).addListener(eq(AgentSpecUtils.AGENTSPEC_INDEX_DATA_ID),
|
||||
eq(AgentSpecUtils.buildAgentSpecGroup(SPEC_NAME)), any(Listener.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSubscribeAgentSpecHandlesNoIndex() throws Exception {
|
||||
mockIndex(SPEC_NAME, null);
|
||||
AgentSpec spec = cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
assertNull(spec);
|
||||
// Manifest listener is added even without index
|
||||
verify(configService, times(1)).addListener(eq(AgentSpecUtils.AGENTSPEC_INDEX_DATA_ID),
|
||||
eq(AgentSpecUtils.buildAgentSpecGroup(SPEC_NAME)), any(Listener.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUnsubscribeAgentSpecBlankNameNoOp() {
|
||||
cacheHolder.unsubscribeAgentSpec(null);
|
||||
verify(configService, never()).removeListener(anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUnsubscribeAgentSpecRemovesListeners() throws Exception {
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"v1\",\"files\":[\"f.json\"]}");
|
||||
mockResource(SPEC_NAME, VERSION, "f.json",
|
||||
"{\"name\":\"r1\",\"type\":\"tool\",\"content\":\"x\"}");
|
||||
cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
cacheHolder.unsubscribeAgentSpec(SPEC_NAME);
|
||||
verify(configService, times(1)).removeListener(eq(AgentSpecUtils.AGENTSPEC_INDEX_DATA_ID),
|
||||
eq(AgentSpecUtils.buildAgentSpecGroup(SPEC_NAME)), any(Listener.class));
|
||||
// resource listener also removed
|
||||
verify(configService, times(1)).removeListener(eq("f.json"),
|
||||
eq(AgentSpecUtils.buildAgentSpecVersionGroup(SPEC_NAME, VERSION)),
|
||||
any(Listener.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testUnsubscribeAgentSpecNotSubscribedNoOp() {
|
||||
cacheHolder.unsubscribeAgentSpec("nonexistent-spec");
|
||||
verify(configService, never()).removeListener(anyString(), anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testShutdownUnsubscribesAll() throws Exception {
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"v1\",\"files\":[]}");
|
||||
cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
cacheHolder.shutdown();
|
||||
// After shutdown subscription map is cleared
|
||||
Map<String, ?> subs = readField(cacheHolder, "subscriptionMap");
|
||||
assertEquals(0, subs.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void testManifestListenerVersionChangeReSubscribesResources() throws Exception {
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"v1\",\"files\":[\"f1.json\"]}");
|
||||
mockResource(SPEC_NAME, VERSION, "f1.json",
|
||||
"{\"name\":\"r1\",\"type\":\"tool\",\"content\":\"x\"}");
|
||||
cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
|
||||
// Capture the manifest listener
|
||||
ArgumentCaptor<Listener> listenerCaptor = ArgumentCaptor.forClass(Listener.class);
|
||||
verify(configService).addListener(eq(AgentSpecUtils.AGENTSPEC_INDEX_DATA_ID),
|
||||
eq(AgentSpecUtils.buildAgentSpecGroup(SPEC_NAME)), listenerCaptor.capture());
|
||||
Listener manifestListener = listenerCaptor.getValue();
|
||||
@Override
|
||||
public Class<? extends Event> subscribeType() {
|
||||
return AgentSpecChangedEvent.class;
|
||||
}
|
||||
|
||||
// Switch to v2 with new file
|
||||
when(configService.getConfig(eq("f2.json"),
|
||||
eq(AgentSpecUtils.buildAgentSpecVersionGroup(SPEC_NAME, "v2")), anyLong()))
|
||||
.thenReturn("{\"name\":\"r2\",\"type\":\"tool\",\"content\":\"y\"}");
|
||||
when(configService.getConfig(eq(AgentSpecUtils.AGENTSPEC_INDEX_DATA_ID),
|
||||
eq(AgentSpecUtils.buildAgentSpecGroup(SPEC_NAME)), anyLong()))
|
||||
.thenReturn("{\"version\":\"v2\",\"files\":[\"f2.json\"]}");
|
||||
boolean await(long timeoutMs) throws InterruptedException {
|
||||
return latch.await(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
manifestListener.receiveConfigInfo(
|
||||
"{\"version\":\"v2\",\"files\":[\"f2.json\"]}");
|
||||
// f1 listener removed, f2 listener added
|
||||
verify(configService, times(1)).removeListener(eq("f1.json"),
|
||||
eq(AgentSpecUtils.buildAgentSpecVersionGroup(SPEC_NAME, VERSION)),
|
||||
any(Listener.class));
|
||||
verify(configService, times(1)).addListener(eq("f2.json"),
|
||||
eq(AgentSpecUtils.buildAgentSpecVersionGroup(SPEC_NAME, "v2")),
|
||||
any(Listener.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testManifestListenerNoSubscriptionEarlyReturn() throws Exception {
|
||||
Method onManifestChanged = NacosAgentSpecCacheHolder.class.getDeclaredMethod(
|
||||
"onManifestChanged", String.class, String.class);
|
||||
onManifestChanged.setAccessible(true);
|
||||
// Should not throw and not interact with configService
|
||||
onManifestChanged.invoke(cacheHolder, "no-such-spec", "{}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParseAgentSpecIndexBlank() throws Exception {
|
||||
Method m = NacosAgentSpecCacheHolder.class.getDeclaredMethod("parseAgentSpecIndex",
|
||||
String.class);
|
||||
m.setAccessible(true);
|
||||
assertNull(m.invoke(null, ""));
|
||||
assertNull(m.invoke(null, (String) null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testParseAgentSpecIndexInvalidJson() throws Exception {
|
||||
Method m = NacosAgentSpecCacheHolder.class.getDeclaredMethod("parseAgentSpecIndex",
|
||||
String.class);
|
||||
m.setAccessible(true);
|
||||
assertNull(m.invoke(null, "not-json{"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsAgentSpecChangedNullOldReturnsTrue() throws Exception {
|
||||
Method m = NacosAgentSpecCacheHolder.class.getDeclaredMethod("isAgentSpecChanged",
|
||||
AgentSpec.class, AgentSpec.class);
|
||||
m.setAccessible(true);
|
||||
AgentSpec n = new AgentSpec();
|
||||
n.setName("a");
|
||||
assertTrue((boolean) m.invoke(cacheHolder, null, n));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsAgentSpecChangedSameReturnsFalse() throws Exception {
|
||||
Method m = NacosAgentSpecCacheHolder.class.getDeclaredMethod("isAgentSpecChanged",
|
||||
AgentSpec.class, AgentSpec.class);
|
||||
m.setAccessible(true);
|
||||
AgentSpec o = new AgentSpec();
|
||||
o.setName("a");
|
||||
AgentSpec n = new AgentSpec();
|
||||
n.setName("a");
|
||||
assertTrue(!(boolean) m.invoke(cacheHolder, o, n));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsAgentSpecChangedDifferentReturnsTrue() throws Exception {
|
||||
Method m = NacosAgentSpecCacheHolder.class.getDeclaredMethod("isAgentSpecChanged",
|
||||
AgentSpec.class, AgentSpec.class);
|
||||
m.setAccessible(true);
|
||||
AgentSpec o = new AgentSpec();
|
||||
o.setName("a");
|
||||
AgentSpec n = new AgentSpec();
|
||||
n.setName("b");
|
||||
assertTrue((boolean) m.invoke(cacheHolder, o, n));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testManifestListenerVersionGoesNull() throws Exception {
|
||||
// Set up subscription with v1 + a file
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"v1\",\"files\":[\"f1.json\"]}");
|
||||
mockResource(SPEC_NAME, VERSION, "f1.json",
|
||||
"{\"name\":\"r1\",\"type\":\"tool\",\"content\":\"x\"}");
|
||||
cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
// Now invoke onManifestChanged with empty index → version cleared, resources unsubscribed
|
||||
Method onManifestChanged = NacosAgentSpecCacheHolder.class.getDeclaredMethod(
|
||||
"onManifestChanged", String.class, String.class);
|
||||
onManifestChanged.setAccessible(true);
|
||||
onManifestChanged.invoke(cacheHolder, SPEC_NAME, "");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testManifestListenerExceptionPath() throws Exception {
|
||||
// First create a subscription so onManifestChanged finds a sub
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"v1\",\"files\":[\"f1.json\"]}");
|
||||
mockResource(SPEC_NAME, VERSION, "f1.json",
|
||||
"{\"name\":\"r1\",\"type\":\"tool\",\"content\":\"x\"}");
|
||||
cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
// Make configService throw on subsequent getConfig to push into the catch block
|
||||
when(configService.getConfig(eq(AgentSpecUtils.AGENTSPEC_INDEX_DATA_ID),
|
||||
eq(AgentSpecUtils.buildAgentSpecGroup(SPEC_NAME)), anyLong()))
|
||||
.thenThrow(new NacosException(500, "boom"));
|
||||
Method onManifestChanged = NacosAgentSpecCacheHolder.class.getDeclaredMethod(
|
||||
"onManifestChanged", String.class, String.class);
|
||||
onManifestChanged.setAccessible(true);
|
||||
// Should not throw — Exception is caught
|
||||
onManifestChanged.invoke(cacheHolder, SPEC_NAME, "{\"version\":\"v2\",\"files\":[]}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReloadAndPublishRemovesWhenNewSpecIsNull() throws Exception {
|
||||
// Pre-cache a spec via subscribe + valid index
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"v1\",\"files\":[\"manifest.json\"]}");
|
||||
mockResource(SPEC_NAME, VERSION, "manifest.json",
|
||||
"{\"name\":\"manifest.json\",\"content\":\"{}\"}");
|
||||
cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
// Now invalidate index so loadAgentSpecFromConfig returns null
|
||||
when(configService.getConfig(eq(AgentSpecUtils.AGENTSPEC_INDEX_DATA_ID),
|
||||
eq(AgentSpecUtils.buildAgentSpecGroup(SPEC_NAME)), anyLong()))
|
||||
.thenReturn("");
|
||||
Method reloadAndPublish = NacosAgentSpecCacheHolder.class.getDeclaredMethod(
|
||||
"reloadAndPublish", String.class);
|
||||
reloadAndPublish.setAccessible(true);
|
||||
reloadAndPublish.invoke(cacheHolder, SPEC_NAME);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testReloadAndPublishExceptionSwallowed() throws Exception {
|
||||
// configService returns a value but JacksonUtils throws on toObj path → exception via load
|
||||
when(configService.getConfig(eq(AgentSpecUtils.AGENTSPEC_INDEX_DATA_ID),
|
||||
eq(AgentSpecUtils.buildAgentSpecGroup(SPEC_NAME)), anyLong()))
|
||||
.thenThrow(new NacosException(500, "fail"));
|
||||
Method reloadAndPublish = NacosAgentSpecCacheHolder.class.getDeclaredMethod(
|
||||
"reloadAndPublish", String.class);
|
||||
reloadAndPublish.setAccessible(true);
|
||||
// Should not throw (catch (Exception e))
|
||||
reloadAndPublish.invoke(cacheHolder, SPEC_NAME);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testSubscribeResourcesAddListenerThrowsSwallowed() throws Exception {
|
||||
// Mock addListener to throw NacosException for resource subscription
|
||||
when(configService.getConfig(eq(AgentSpecUtils.AGENTSPEC_INDEX_DATA_ID),
|
||||
eq(AgentSpecUtils.buildAgentSpecGroup(SPEC_NAME)), anyLong()))
|
||||
.thenReturn("{\"version\":\"v1\",\"files\":[\"f.json\"]}");
|
||||
when(configService.getConfig(eq("f.json"),
|
||||
eq(AgentSpecUtils.buildAgentSpecVersionGroup(SPEC_NAME, VERSION)), anyLong()))
|
||||
.thenReturn("{\"name\":\"r1\",\"type\":\"tool\",\"content\":\"x\"}");
|
||||
org.mockito.Mockito.doThrow(new NacosException(500, "fail")).when(configService)
|
||||
.addListener(eq("f.json"),
|
||||
eq(AgentSpecUtils.buildAgentSpecVersionGroup(SPEC_NAME, VERSION)),
|
||||
any(Listener.class));
|
||||
// Should not throw — internal try/catch swallows
|
||||
cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
}
|
||||
|
||||
@Test
|
||||
void testManifestListenerGetExecutorReturnsNull() throws Exception {
|
||||
mockIndex(SPEC_NAME, "{\"version\":\"v1\",\"files\":[\"f1.json\"]}");
|
||||
mockResource(SPEC_NAME, VERSION, "f1.json",
|
||||
"{\"name\":\"r1\",\"type\":\"tool\",\"content\":\"x\"}");
|
||||
cacheHolder.subscribeAgentSpec(SPEC_NAME);
|
||||
// Capture manifest listener and call its getExecutor + receiveConfigInfo
|
||||
org.mockito.ArgumentCaptor<Listener> captor =
|
||||
org.mockito.ArgumentCaptor.forClass(Listener.class);
|
||||
verify(configService).addListener(eq(AgentSpecUtils.AGENTSPEC_INDEX_DATA_ID),
|
||||
eq(AgentSpecUtils.buildAgentSpecGroup(SPEC_NAME)), captor.capture());
|
||||
Listener manifestListener = captor.getValue();
|
||||
assertNull(manifestListener.getExecutor());
|
||||
// Capture resource listener (file f1.json) and call its getExecutor + receive
|
||||
org.mockito.ArgumentCaptor<Listener> resCap =
|
||||
org.mockito.ArgumentCaptor.forClass(Listener.class);
|
||||
verify(configService).addListener(eq("f1.json"),
|
||||
eq(AgentSpecUtils.buildAgentSpecVersionGroup(SPEC_NAME, VERSION)), resCap.capture());
|
||||
Listener resourceListener = resCap.getValue();
|
||||
assertNull(resourceListener.getExecutor());
|
||||
// receiveConfigInfo on resource listener triggers onResourceChanged → reloadAndPublish
|
||||
resourceListener.receiveConfigInfo("{}");
|
||||
}
|
||||
|
||||
@Test
|
||||
void testIsAgentSpecChangedNullOldLogsAndReturnsTrue() throws Exception {
|
||||
// Already covered by testIsAgentSpecChangedNullOldReturnsTrue but ensure newJson log path
|
||||
Method m = NacosAgentSpecCacheHolder.class.getDeclaredMethod("isAgentSpecChanged",
|
||||
AgentSpec.class, AgentSpec.class);
|
||||
m.setAccessible(true);
|
||||
// Pass null new and null old → newJson="null", returns true
|
||||
assertTrue((boolean) m.invoke(cacheHolder, null, null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void testAgentSpecIndexInnerGettersAndSetters() throws Exception {
|
||||
Class<?> indexClass = Class.forName(
|
||||
"com.alibaba.nacos.client.ai.cache.NacosAgentSpecCacheHolder$AgentSpecIndex");
|
||||
java.lang.reflect.Constructor<?> ctor = indexClass.getDeclaredConstructor();
|
||||
ctor.setAccessible(true);
|
||||
Object idx = ctor.newInstance();
|
||||
Method setVersion = indexClass.getDeclaredMethod("setVersion", String.class);
|
||||
setVersion.setAccessible(true);
|
||||
setVersion.invoke(idx, "v1");
|
||||
Method getVersion = indexClass.getDeclaredMethod("getVersion");
|
||||
getVersion.setAccessible(true);
|
||||
assertEquals("v1", getVersion.invoke(idx));
|
||||
Method setFiles = indexClass.getDeclaredMethod("setFiles", java.util.List.class);
|
||||
setFiles.setAccessible(true);
|
||||
setFiles.invoke(idx, java.util.Collections.singletonList("a.json"));
|
||||
Method getFiles = indexClass.getDeclaredMethod("getFiles");
|
||||
getFiles.setAccessible(true);
|
||||
assertEquals(java.util.Collections.singletonList("a.json"), getFiles.invoke(idx));
|
||||
void reset() {
|
||||
invokedMark.set(false);
|
||||
latch = new CountDownLatch(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+264
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.client.ai.cache;
|
||||
|
||||
import com.alibaba.nacos.api.ai.constant.AiConstants;
|
||||
import com.alibaba.nacos.api.exception.NacosException;
|
||||
import com.alibaba.nacos.client.ai.event.SkillChangedEvent;
|
||||
import com.alibaba.nacos.client.ai.remote.AiClientProxy;
|
||||
import com.alibaba.nacos.client.ai.remote.SkillQueryResponse;
|
||||
import com.alibaba.nacos.client.env.NacosClientProperties;
|
||||
import com.alibaba.nacos.common.notify.Event;
|
||||
import com.alibaba.nacos.common.notify.NotifyCenter;
|
||||
import com.alibaba.nacos.common.notify.listener.Subscriber;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class NacosSkillCacheHolderTest {
|
||||
|
||||
@Mock
|
||||
private AiClientProxy aiClientProxy;
|
||||
|
||||
private NacosSkillCacheHolder cacheHolder;
|
||||
|
||||
private final List<MockSkillEventSubscriber> registeredSubscribers = new ArrayList<>();
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
Properties properties = new Properties();
|
||||
properties.put(AiConstants.AI_SKILL_CACHE_UPDATE_INTERVAL, "100");
|
||||
NotifyCenter.registerToPublisher(SkillChangedEvent.class, 16384);
|
||||
cacheHolder = new NacosSkillCacheHolder(aiClientProxy,
|
||||
NacosClientProperties.PROTOTYPE.derive(properties));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void tearDown() throws NacosException {
|
||||
for (MockSkillEventSubscriber each : registeredSubscribers) {
|
||||
NotifyCenter.deregisterSubscriber(each);
|
||||
}
|
||||
registeredSubscribers.clear();
|
||||
cacheHolder.shutdown();
|
||||
NotifyCenter.deregisterPublisher(SkillChangedEvent.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribeSkillShouldReturnNullAndScheduleWhenNotFound() throws Exception {
|
||||
when(aiClientProxy.querySkill("s1", "1.0.0", null, null))
|
||||
.thenThrow(new NacosException(NacosException.NOT_FOUND, "not found"));
|
||||
|
||||
byte[] result = cacheHolder.subscribeSkill("s1", "1.0.0", null);
|
||||
|
||||
assertNull(result);
|
||||
assertEquals(1, getUpdateTaskMap().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribeSkillShouldCacheButNotPublishEventWhenFound() throws Exception {
|
||||
byte[] zipBytes = new byte[] {0x50, 0x4B, 0x03, 0x04};
|
||||
SkillQueryResponse response = new SkillQueryResponse(zipBytes, "m1", "1.0.0");
|
||||
when(aiClientProxy.querySkill("s1", "1.0.0", null, null)).thenReturn(response);
|
||||
MockSkillEventSubscriber subscriber = registerMockSubscriber();
|
||||
|
||||
byte[] result = cacheHolder.subscribeSkill("s1", "1.0.0", null);
|
||||
|
||||
assertArrayEquals(zipBytes, result);
|
||||
assertEquals("m1", getMd5Cache().get("s1::version:1.0.0"));
|
||||
// Initial subscribe must NOT publish event; the caller (NacosAiService)
|
||||
// is responsible for the first listener notification to avoid double-invocation.
|
||||
assertFalse(subscriber.await(200),
|
||||
"Initial subscribe should not publish event via NotifyCenter");
|
||||
assertFalse(subscriber.invokedMark.get(), "Subscriber should not be invoked");
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribeSkillShouldThrowWhenSkillNameBlank() {
|
||||
assertThrows(NacosException.class,
|
||||
() -> cacheHolder.subscribeSkill("", "1.0.0", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updaterShouldIgnoreWhenNotModified() throws Exception {
|
||||
byte[] zipBytes = new byte[] {0x50, 0x4B};
|
||||
SkillQueryResponse response = new SkillQueryResponse(zipBytes, "m1", "1.0.0");
|
||||
when(aiClientProxy.querySkill("s1", "1.0.0", null, null)).thenReturn(response);
|
||||
when(aiClientProxy.querySkill("s1", "1.0.0", null, "m1"))
|
||||
.thenThrow(new NacosException(NacosException.NOT_MODIFIED, "up to date"));
|
||||
cacheHolder.subscribeSkill("s1", "1.0.0", null);
|
||||
MockSkillEventSubscriber subscriber = registerMockSubscriber();
|
||||
|
||||
Runnable updater = getOnlyUpdater();
|
||||
updater.run();
|
||||
|
||||
assertEquals("m1", getMd5Cache().get("s1::version:1.0.0"));
|
||||
assertFalse(subscriber.await(200), "Not modified skill should not publish event");
|
||||
assertFalse(subscriber.invokedMark.get(), "Subscriber should not be invoked");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updaterShouldEvictAndPublishNullEventWhenNotFound() throws Exception {
|
||||
byte[] zipBytes = new byte[] {0x50, 0x4B};
|
||||
SkillQueryResponse response = new SkillQueryResponse(zipBytes, "m1", "1.0.0");
|
||||
when(aiClientProxy.querySkill("s1", "1.0.0", null, null)).thenReturn(response);
|
||||
when(aiClientProxy.querySkill("s1", "1.0.0", null, "m1"))
|
||||
.thenThrow(new NacosException(NacosException.NOT_FOUND, "not found"));
|
||||
cacheHolder.subscribeSkill("s1", "1.0.0", null);
|
||||
MockSkillEventSubscriber subscriber = registerMockSubscriber();
|
||||
|
||||
Runnable updater = getOnlyUpdater();
|
||||
updater.run();
|
||||
|
||||
assertNull(getMd5Cache().get("s1::version:1.0.0"));
|
||||
assertFalse(subscriber.await(200),
|
||||
"Not found should not trigger an event when response is null");
|
||||
}
|
||||
|
||||
@Test
|
||||
void updaterShouldPublishEventWhenMd5Changed() throws Exception {
|
||||
byte[] zip1 = new byte[] {0x01};
|
||||
byte[] zip2 = new byte[] {0x02};
|
||||
SkillQueryResponse first = new SkillQueryResponse(zip1, "m1", "1.0.0");
|
||||
SkillQueryResponse second = new SkillQueryResponse(zip2, "m2", "1.0.0");
|
||||
when(aiClientProxy.querySkill("s1", "1.0.0", null, null)).thenReturn(first);
|
||||
when(aiClientProxy.querySkill("s1", "1.0.0", null, "m1")).thenReturn(second);
|
||||
cacheHolder.subscribeSkill("s1", "1.0.0", null);
|
||||
MockSkillEventSubscriber subscriber = registerMockSubscriber();
|
||||
|
||||
Runnable updater = getOnlyUpdater();
|
||||
updater.run();
|
||||
|
||||
assertEquals("m2", getMd5Cache().get("s1::version:1.0.0"));
|
||||
assertTrue(subscriber.await(5000), "Changed skill should publish event");
|
||||
assertTrue(subscriber.invokedMark.get());
|
||||
}
|
||||
|
||||
@Test
|
||||
void unsubscribeSkillShouldCancelTaskAndRemoveCache() throws Exception {
|
||||
SkillQueryResponse response =
|
||||
new SkillQueryResponse(new byte[] {0x01}, "m1", "1.0.0");
|
||||
when(aiClientProxy.querySkill("s1", "1.0.0", null, null)).thenReturn(response);
|
||||
cacheHolder.subscribeSkill("s1", "1.0.0", null);
|
||||
|
||||
cacheHolder.unsubscribeSkill("s1", "1.0.0", null);
|
||||
|
||||
assertTrue(getUpdateTaskMap().isEmpty());
|
||||
assertNull(getMd5Cache().get("s1::version:1.0.0"));
|
||||
verify(aiClientProxy, never()).querySkill("s1", null, null, null);
|
||||
}
|
||||
|
||||
@Test
|
||||
void subscribeSkillShouldThrowWhenUnexpectedException() throws Exception {
|
||||
when(aiClientProxy.querySkill("s1", "1.0.0", null, null))
|
||||
.thenThrow(new NacosException(NacosException.SERVER_ERROR, "server error"));
|
||||
|
||||
assertThrows(NacosException.class,
|
||||
() -> cacheHolder.subscribeSkill("s1", "1.0.0", null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void updaterShouldIgnoreGeneralExceptionAndKeepCache() throws Exception {
|
||||
SkillQueryResponse response =
|
||||
new SkillQueryResponse(new byte[] {0x01}, "m1", "1.0.0");
|
||||
when(aiClientProxy.querySkill("s1", "1.0.0", null, null)).thenReturn(response);
|
||||
when(aiClientProxy.querySkill("s1", "1.0.0", null, "m1"))
|
||||
.thenThrow(new NacosException(NacosException.SERVER_ERROR, "server error"));
|
||||
cacheHolder.subscribeSkill("s1", "1.0.0", null);
|
||||
|
||||
Runnable updater = getOnlyUpdater();
|
||||
updater.run();
|
||||
|
||||
assertNotNull(getMd5Cache().get("s1::version:1.0.0"));
|
||||
assertEquals(1, getUpdateTaskMap().size());
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, String> getMd5Cache() throws Exception {
|
||||
Field field = NacosSkillCacheHolder.class.getDeclaredField("skillMd5Cache");
|
||||
field.setAccessible(true);
|
||||
return (Map<String, String>) field.get(cacheHolder);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private Map<String, Object> getUpdateTaskMap() throws Exception {
|
||||
Field field = NacosSkillCacheHolder.class.getDeclaredField("updateTaskMap");
|
||||
field.setAccessible(true);
|
||||
return (Map<String, Object>) field.get(cacheHolder);
|
||||
}
|
||||
|
||||
private Runnable getOnlyUpdater() throws Exception {
|
||||
Object updater = getUpdateTaskMap().values().iterator().next();
|
||||
return (Runnable) updater;
|
||||
}
|
||||
|
||||
private MockSkillEventSubscriber registerMockSubscriber() {
|
||||
MockSkillEventSubscriber subscriber = new MockSkillEventSubscriber();
|
||||
NotifyCenter.registerSubscriber(subscriber);
|
||||
registeredSubscribers.add(subscriber);
|
||||
return subscriber;
|
||||
}
|
||||
|
||||
private static class MockSkillEventSubscriber extends Subscriber<SkillChangedEvent> {
|
||||
|
||||
private final AtomicBoolean invokedMark = new AtomicBoolean(false);
|
||||
private volatile CountDownLatch latch = new CountDownLatch(1);
|
||||
|
||||
@Override
|
||||
public void onEvent(SkillChangedEvent event) {
|
||||
invokedMark.set(true);
|
||||
latch.countDown();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Class<? extends Event> subscribeType() {
|
||||
return SkillChangedEvent.class;
|
||||
}
|
||||
|
||||
boolean await(long timeoutMs) throws InterruptedException {
|
||||
return latch.await(timeoutMs, TimeUnit.MILLISECONDS);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
invokedMark.set(false);
|
||||
latch = new CountDownLatch(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
/*
|
||||
* Copyright 1999-2025 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.example;
|
||||
|
||||
import com.alibaba.nacos.api.ai.AiFactory;
|
||||
import com.alibaba.nacos.api.ai.AiService;
|
||||
import com.alibaba.nacos.api.ai.listener.AbstractNacosAgentSpecListener;
|
||||
import com.alibaba.nacos.api.ai.listener.NacosAgentSpecEvent;
|
||||
import com.alibaba.nacos.api.ai.model.agentspecs.AgentSpec;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* AgentSpec client integration example.
|
||||
*
|
||||
* <p>Tests the HTTP polling + 304 conditional query mechanism:
|
||||
* <ol>
|
||||
* <li>Subscribe to an AgentSpec and verify initial fetch</li>
|
||||
* <li>Wait for polling cycles and observe 304 Not Modified behavior</li>
|
||||
* <li>Unsubscribe and verify cleanup</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Prerequisites: A local Nacos server running at localhost:8848
|
||||
* with at least one published AgentSpec (e.g., "test").
|
||||
*/
|
||||
public class AgentSpecExample {
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
String serverAddr = "localhost:8848";
|
||||
String agentSpecName = "test";
|
||||
|
||||
// Allow override via command line
|
||||
if (args.length > 0) {
|
||||
agentSpecName = args[0];
|
||||
}
|
||||
if (args.length > 1) {
|
||||
serverAddr = args[1];
|
||||
}
|
||||
|
||||
System.out.println("============================================");
|
||||
System.out.println(" AgentSpec Client Integration Test");
|
||||
System.out.println("============================================");
|
||||
System.out.println("[Config] serverAddr = " + serverAddr);
|
||||
System.out.println("[Config] agentSpecName = " + agentSpecName);
|
||||
System.out.println();
|
||||
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("serverAddr", serverAddr);
|
||||
properties.setProperty("namespace", "public");
|
||||
properties.setProperty("username", "nacos");
|
||||
properties.setProperty("password", "nacos");
|
||||
// AgentSpec polling uses HTTP transport
|
||||
properties.setProperty("nacosAiTransportMode", "http");
|
||||
|
||||
System.out.println("[Step 1] Creating AiService...");
|
||||
AiService aiService = AiFactory.createAiService(properties);
|
||||
System.out.println("[Step 1] AiService created successfully.");
|
||||
System.out.println();
|
||||
|
||||
// === Test: Subscribe to AgentSpec ===
|
||||
System.out.println("[Step 2] Subscribing to AgentSpec: " + agentSpecName);
|
||||
CountDownLatch eventLatch = new CountDownLatch(1);
|
||||
AtomicInteger eventCount = new AtomicInteger(0);
|
||||
|
||||
AbstractNacosAgentSpecListener listener = new AbstractNacosAgentSpecListener() {
|
||||
|
||||
@Override
|
||||
public void onEvent(NacosAgentSpecEvent event) {
|
||||
int count = eventCount.incrementAndGet();
|
||||
System.out.println();
|
||||
System.out.println("[Event #" + count + "] AgentSpec changed!");
|
||||
System.out.println(" name: " + event.getAgentSpecName());
|
||||
AgentSpec spec = event.getAgentSpec();
|
||||
if (spec != null) {
|
||||
System.out.println(" description: " + spec.getDescription());
|
||||
System.out.println(" content length: "
|
||||
+ (spec.getContent() != null ? spec.getContent().length() : 0));
|
||||
System.out.println(" resource count: "
|
||||
+ (spec.getResource() != null ? spec.getResource().size() : 0));
|
||||
} else {
|
||||
System.out.println(" spec: null (deleted or not found)");
|
||||
}
|
||||
eventLatch.countDown();
|
||||
}
|
||||
};
|
||||
|
||||
AgentSpec initialSpec = aiService.subscribeAgentSpec(agentSpecName, listener);
|
||||
System.out.println("[Step 2] Subscribe completed.");
|
||||
if (initialSpec != null) {
|
||||
System.out.println(" Initial AgentSpec loaded:");
|
||||
System.out.println(" name: " + initialSpec.getName());
|
||||
System.out.println(" description: " + initialSpec.getDescription());
|
||||
System.out.println(" content length: "
|
||||
+ (initialSpec.getContent() != null ? initialSpec.getContent().length() : 0));
|
||||
System.out.println(" resource count: "
|
||||
+ (initialSpec.getResource() != null ? initialSpec.getResource().size() : 0));
|
||||
} else {
|
||||
System.out.println(" Initial AgentSpec: null (not found)");
|
||||
}
|
||||
System.out.println();
|
||||
|
||||
// === Wait for polling cycles ===
|
||||
System.out.println("[Step 3] Waiting 25 seconds to observe polling behavior...");
|
||||
System.out.println(" (Polling interval is ~10s, expecting 2 poll cycles with 304)");
|
||||
System.out.println(" (If you update the AgentSpec on server during this time,");
|
||||
System.out.println(" you should see additional [Event] callbacks above)");
|
||||
System.out.println();
|
||||
|
||||
// Wait regardless of initial event - we want to observe polling 304s
|
||||
Thread.sleep(25000);
|
||||
System.out.println("[Step 3] Done waiting. Total events: " + eventCount.get());
|
||||
System.out
|
||||
.println(" (Only 1 event = initial load. No extra events = 304 working correctly)");
|
||||
System.out.println();
|
||||
|
||||
// === Test: Unsubscribe ===
|
||||
System.out.println("[Step 4] Unsubscribing from AgentSpec: " + agentSpecName);
|
||||
aiService.unsubscribeAgentSpec(agentSpecName, listener);
|
||||
System.out.println("[Step 4] Unsubscribed. Polling should stop.");
|
||||
System.out.println();
|
||||
|
||||
// Wait a bit to confirm no more polling
|
||||
System.out
|
||||
.println("[Step 5] Waiting 15 seconds to verify no more polling after unsubscribe...");
|
||||
Thread.sleep(15000);
|
||||
System.out.println("[Step 5] Done. Total events received: " + eventCount.get());
|
||||
System.out.println();
|
||||
|
||||
// === Shutdown ===
|
||||
System.out.println("[Step 6] Shutting down AiService...");
|
||||
aiService.shutdown();
|
||||
System.out.println("[Step 6] Shutdown complete.");
|
||||
System.out.println();
|
||||
System.out.println("============================================");
|
||||
System.out.println(" Test Complete - All steps passed!");
|
||||
System.out.println("============================================");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* Copyright 1999-2026 Alibaba Group Holding Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package com.alibaba.nacos.example;
|
||||
|
||||
import com.alibaba.nacos.api.ai.AiFactory;
|
||||
import com.alibaba.nacos.api.ai.AiService;
|
||||
import com.alibaba.nacos.api.ai.listener.AbstractNacosSkillListener;
|
||||
import com.alibaba.nacos.api.ai.listener.NacosSkillEvent;
|
||||
import java.util.Properties;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
/**
|
||||
* Skill client integration example.
|
||||
*
|
||||
* <p>Tests the HTTP polling + 304 conditional query mechanism for Skill resources:
|
||||
* <ol>
|
||||
* <li>Subscribe to a Skill and verify initial fetch</li>
|
||||
* <li>Wait for polling cycles and observe 304 Not Modified behavior</li>
|
||||
* <li>Unsubscribe and verify cleanup</li>
|
||||
* </ol>
|
||||
*
|
||||
* <p>Prerequisites: A local Nacos server running at localhost:8848
|
||||
* with at least one published Skill (e.g., "nacos-cli-e2e-skill").
|
||||
*/
|
||||
public class SkillExample {
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
String serverAddr = "localhost:8848";
|
||||
String skillName = "nacos-cli-e2e-skill";
|
||||
String label = "latest";
|
||||
|
||||
// Allow override via command line
|
||||
if (args.length > 0) {
|
||||
skillName = args[0];
|
||||
}
|
||||
if (args.length > 1) {
|
||||
label = args[1];
|
||||
}
|
||||
if (args.length > 2) {
|
||||
serverAddr = args[2];
|
||||
}
|
||||
|
||||
System.out.println("============================================");
|
||||
System.out.println(" Skill Client Integration Test");
|
||||
System.out.println("============================================");
|
||||
System.out.println("[Config] serverAddr = " + serverAddr);
|
||||
System.out.println("[Config] skillName = " + skillName);
|
||||
System.out.println("[Config] label = " + label);
|
||||
System.out.println();
|
||||
|
||||
Properties properties = new Properties();
|
||||
properties.setProperty("serverAddr", serverAddr);
|
||||
properties.setProperty("namespace", "public");
|
||||
properties.setProperty("username", "nacos");
|
||||
properties.setProperty("password", "nacos");
|
||||
// Skill download/polling uses HTTP transport
|
||||
properties.setProperty("nacosAiTransportMode", "http");
|
||||
|
||||
System.out.println("[Step 1] Creating AiService...");
|
||||
AiService aiService = AiFactory.createAiService(properties);
|
||||
System.out.println("[Step 1] AiService created successfully.");
|
||||
System.out.println();
|
||||
|
||||
// === Test: Subscribe to Skill ===
|
||||
System.out.println("[Step 2] Subscribing to Skill: " + skillName + ", label=" + label);
|
||||
AtomicInteger eventCount = new AtomicInteger(0);
|
||||
|
||||
AbstractNacosSkillListener listener = new AbstractNacosSkillListener() {
|
||||
|
||||
@Override
|
||||
public void onEvent(NacosSkillEvent event) {
|
||||
int count = eventCount.incrementAndGet();
|
||||
System.out.println();
|
||||
System.out.println("[Event #" + count + "] Skill changed!");
|
||||
System.out.println(" name: " + event.getSkillName());
|
||||
System.out.println(" resolvedVersion: " + event.getResolvedVersion());
|
||||
System.out.println(" md5: " + event.getMd5());
|
||||
byte[] zip = event.getZipBytes();
|
||||
System.out.println(" zip length: " + (zip != null ? zip.length : 0));
|
||||
}
|
||||
};
|
||||
|
||||
byte[] initialZip = aiService.subscribeSkill(skillName, null, label, listener);
|
||||
System.out.println("[Step 2] Subscribe completed.");
|
||||
if (initialZip != null) {
|
||||
System.out
|
||||
.println(" Initial Skill zip loaded, size = " + initialZip.length + " bytes");
|
||||
} else {
|
||||
System.out.println(" Initial Skill: null (not found)");
|
||||
}
|
||||
System.out.println();
|
||||
|
||||
// === Wait for polling cycles ===
|
||||
System.out.println("[Step 3] Waiting 25 seconds to observe polling behavior...");
|
||||
System.out.println(" (Polling interval is ~10s, expecting 2 poll cycles with 304)");
|
||||
System.out.println(" (If you update the Skill on server during this time,");
|
||||
System.out.println(" you should see additional [Event] callbacks above)");
|
||||
System.out.println();
|
||||
|
||||
Thread.sleep(25000);
|
||||
System.out.println("[Step 3] Done waiting. Total events: " + eventCount.get());
|
||||
System.out
|
||||
.println(" (Only 1 event = initial load. No extra events = 304 working correctly)");
|
||||
System.out.println();
|
||||
|
||||
// === Test: Unsubscribe ===
|
||||
System.out.println("[Step 4] Unsubscribing from Skill: " + skillName);
|
||||
aiService.unsubscribeSkill(skillName, null, label, listener);
|
||||
System.out.println("[Step 4] Unsubscribed. Polling should stop.");
|
||||
System.out.println();
|
||||
|
||||
// Wait a bit to confirm no more polling
|
||||
System.out
|
||||
.println("[Step 5] Waiting 15 seconds to verify no more polling after unsubscribe...");
|
||||
Thread.sleep(15000);
|
||||
System.out.println("[Step 5] Done. Total events received: " + eventCount.get());
|
||||
System.out.println();
|
||||
|
||||
// === Shutdown ===
|
||||
System.out.println("[Step 6] Shutting down AiService...");
|
||||
aiService.shutdown();
|
||||
System.out.println("[Step 6] Shutdown complete.");
|
||||
System.out.println();
|
||||
System.out.println("============================================");
|
||||
System.out.println(" Test Complete - All steps passed!");
|
||||
System.out.println("============================================");
|
||||
}
|
||||
}
|
||||
@@ -69,6 +69,25 @@ latest. Subscriptions should notify clients when the resolved AgentSpec changes.
|
||||
Runtime clients should not receive upload, publish, force publish, delete, or
|
||||
broad management listing operations.
|
||||
|
||||
### 5.1 Client Listener Protocol
|
||||
|
||||
The client uses HTTP polling with a conditional query (MD5-based ETag) to detect
|
||||
content changes without downloading the full payload every cycle.
|
||||
|
||||
- **Polling interval**: configurable via `nacosAiAgentSpecCacheUpdateInterval`;
|
||||
default 10 000 ms.
|
||||
- **Request**: `GET /v3/client/ai/agentspec?namespaceId=&name=&md5=<cached-md5>`.
|
||||
- **304 Not Modified**: server compares the request MD5 against the stored
|
||||
`contentMd5` (computed at publish time). If they match the server returns
|
||||
HTTP 304 with an `ETag` header; the client keeps its local cache unchanged.
|
||||
- **200 OK**: the response carries `Result<AgentSpec>` JSON with response headers
|
||||
`X-Nacos-AgentSpec-Md5` and `X-Nacos-AgentSpec-Resolved-Version`. The client
|
||||
updates its local cache and md5Cache, then publishes an
|
||||
`AgentSpecChangedEvent`.
|
||||
- **Legacy backfill**: for versions published before the contentMd5 field
|
||||
existed, the server lazily computes and stores the MD5 on the first
|
||||
conditional query.
|
||||
|
||||
## 6. Evolution Note
|
||||
|
||||
AgentSpec is expected to evolve with agent framework packaging. Future versions
|
||||
|
||||
@@ -132,6 +132,71 @@ where supported.
|
||||
Runtime clients should not receive broad management operations such as upload,
|
||||
publish, delete, or unrestricted listing.
|
||||
|
||||
Runtime clients may query Skill by `name`, optional `version`, optional
|
||||
`label`, and optional md5. If md5 equals the content md5 of the currently
|
||||
resolved version, the server may return a not-modified error and must not
|
||||
include a ZIP body. When the client does not send md5, the server must return
|
||||
the current content as a ZIP together with the corresponding md5. This
|
||||
contract supports polling-based listening; subscriptions should report Skill
|
||||
content changes through md5 transitions without exposing broad management
|
||||
listing behavior to runtime clients.
|
||||
|
||||
Skill content md5 is a version-scoped field. It must be computed once when an
|
||||
upload or publish writes version content and must be persisted with
|
||||
`ai_resource_version`; runtime query paths must not recompute it. The md5
|
||||
input is the full set of package bytes of the published version (`SKILL.md`
|
||||
and all referenced resources), and its scope must match the ZIP bytes returned
|
||||
on download so that an md5 hit on the client never corresponds to different
|
||||
server-side bytes.
|
||||
|
||||
For versions that exist before the listening contract is enabled and therefore
|
||||
lack md5, the server must backfill md5 with the same input scope on the first
|
||||
listening-style query and return that md5 in the same response. While md5 is
|
||||
missing or backfill fails, the server must return a 200 response with the ZIP
|
||||
and must not return not-modified.
|
||||
|
||||
### 6.1 Client Polling Listener Contract
|
||||
|
||||
Nacos does not push Skill changes; the client SDK realizes listener semantics
|
||||
by periodically issuing a conditional `GET /v3/client/ai/skills`. The listener
|
||||
contract is composed of the following requirements that both the server and
|
||||
any SDK implementing this contract must respect:
|
||||
|
||||
- **Response headers**: A 200 response must carry `Content-Type:
|
||||
application/zip`, `Content-Disposition: attachment;filename=<name>.zip`,
|
||||
`ETag: "<md5>"`, `X-Nacos-Skill-Md5: <md5>`, and
|
||||
`X-Nacos-Skill-Resolved-Version: <version>`. The resolved-version header
|
||||
reflects the actual version after `label`/`latest` routing parameters are
|
||||
resolved.
|
||||
- **304 response**: When the client-supplied md5 equals the md5 of the
|
||||
resolved version, the server returns `304 Not Modified` with an empty body.
|
||||
It must include `ETag` and `X-Nacos-Skill-Md5`. Per RFC 7232 it must not
|
||||
include `Content-Type` and must not include
|
||||
`X-Nacos-Skill-Resolved-Version`, since 304 should not restate entity
|
||||
metadata.
|
||||
- **404 response**: When the skill name is valid but the resource is missing,
|
||||
the server returns `404` with business error code `20004`. Clients must
|
||||
translate this into local cache eviction and emit a content-missing event,
|
||||
and must not treat it as a transient error to retry.
|
||||
- **Polling schedule**: The SDK must adopt a single-threaded `schedule + tail
|
||||
self-reschedule` pattern, so that the next query starts from the previous
|
||||
task's completion time rather than its start time. This avoids request
|
||||
pile-up under slow server responses. The SDK must not use
|
||||
`scheduleAtFixedRate`.
|
||||
- **Default interval**: The default polling interval is `10000` milliseconds
|
||||
(`AiConstants.DEFAULT_AI_CACHE_UPDATE_INTERVAL`). The first query happens
|
||||
one interval after the subscription. Because the subscription itself
|
||||
synchronously primes the cache, the SDK must not issue an immediate
|
||||
additional query.
|
||||
- **Tunable interval**: Clients override the default by passing
|
||||
`nacosAiSkillCacheUpdateInterval`
|
||||
(`AiConstants.AI_SKILL_CACHE_UPDATE_INTERVAL`) through `Properties`, in
|
||||
milliseconds. This setting only applies to Skill and is independent from
|
||||
the polling intervals of Prompt, MCP Server, and AgentCard.
|
||||
- **Cancellation**: `unsubscribeSkill` must cancel the corresponding task,
|
||||
remove the md5 cache entry, and stop emitting polling requests to the
|
||||
server.
|
||||
|
||||
## 7. Pending Alignment Issues
|
||||
|
||||
- Enforce the full upstream name validation rule during upload.
|
||||
|
||||
@@ -64,6 +64,21 @@ AgentSpec 发生变化时通知客户端。
|
||||
|
||||
运行时客户端不应获得 upload、publish、force publish、delete 或宽范围管理列表能力。
|
||||
|
||||
### 5.1 客户端监听协议
|
||||
|
||||
客户端使用 HTTP 轮询 + 条件查询(基于 MD5 的 ETag)检测内容变更,避免每次轮询都下载
|
||||
完整内容。
|
||||
|
||||
- **轮询间隔**:通过 `nacosAiAgentSpecCacheUpdateInterval` 配置,默认 10 000 ms。
|
||||
- **请求**:`GET /v3/client/ai/agentspec?namespaceId=&name=&md5=<cached-md5>`。
|
||||
- **304 Not Modified**:服务端将请求中的 MD5 与存储的 `contentMd5`(发布时预算)比对。
|
||||
若一致则返回 HTTP 304 + `ETag` header,客户端保持本地缓存不变。
|
||||
- **200 OK**:响应携带 `Result<AgentSpec>` JSON 及响应头
|
||||
`X-Nacos-AgentSpec-Md5`、`X-Nacos-AgentSpec-Resolved-Version`。客户端更新本地缓存
|
||||
和 md5Cache,并发布 `AgentSpecChangedEvent`。
|
||||
- **存量回填**:对于 contentMd5 字段不存在的旧版本,服务端在首次条件查询时懒计算并存储
|
||||
MD5。
|
||||
|
||||
## 6. 演进说明
|
||||
|
||||
AgentSpec 预计会随 agent framework 包格式演进。未来版本可能增加 schema 校验、签名、
|
||||
|
||||
@@ -106,6 +106,48 @@ Skill 遵循共享的 [AI 资源生命周期规范](ai-resource-lifecycle-spec.m
|
||||
|
||||
运行时客户端不应获得 upload、publish、delete 或无限制列表等宽管理能力。
|
||||
|
||||
运行时客户端可以通过 `name`、可选 `version`、可选 `label` 和可选 md5 查询
|
||||
Skill。如果 md5 与当前命中版本的内容 md5 一致,服务端可以返回 not-modified 错误,
|
||||
响应不携带 ZIP 主体。客户端不传 md5 时,服务端必须按当前内容返回 ZIP 与对应 md5。
|
||||
该契约用于支持轮询监听,订阅应基于 md5 变更报告 Skill 内容变化,但不应向运行时
|
||||
客户端暴露宽范围管理列表能力。
|
||||
|
||||
Skill 内容 md5 是版本级字段,必须在 upload 或发布写入版本内容时一次性计算并随
|
||||
`ai_resource_version` 持久化,运行时查询不得重新计算。计算输入是发布版本的全部包
|
||||
字节内容(`SKILL.md` 与所有引用资源),计算口径必须与下载返回的 ZIP 字节内容
|
||||
保持一致,避免出现“客户端 md5 命中但服务端会返回不同字节”的偏差。
|
||||
|
||||
对升级前已存在但缺少 md5 的历史版本,服务端首次响应监听类查询时必须按上述口径
|
||||
回填 md5,并在同一次响应中返回该 md5;只要 md5 缺失或回填失败,服务端必须返回
|
||||
带 ZIP 的 200 响应,不得返回 not-modified。
|
||||
|
||||
### 6.1 客户端轮询监听契约
|
||||
|
||||
Nacos 不为 Skill 提供推送通道,客户端 SDK 通过周期性条件查询 `GET /v3/client/ai/skills`
|
||||
实现监听语义。监听契约由以下要素组成,服务端与所有实现该 SDK 契约的客户端必须遵守:
|
||||
|
||||
- **响应头**:200 响应必须携带 `Content-Type: application/zip`、`Content-Disposition:
|
||||
attachment;filename=<name>.zip`、`ETag: "<md5>"`、`X-Nacos-Skill-Md5: <md5>` 与
|
||||
`X-Nacos-Skill-Resolved-Version: <version>`。`X-Nacos-Skill-Resolved-Version` 反映
|
||||
`label`/`latest` 等路由参数解析后的真实版本。
|
||||
- **304 响应**:当客户端传入 md5 与服务端命中版本的 md5 一致时,服务端返回
|
||||
`304 Not Modified`,body 必须为空,必须携带 `ETag` 与 `X-Nacos-Skill-Md5`,按 RFC 7232
|
||||
不得携带 `Content-Type`,且不得携带 `X-Nacos-Skill-Resolved-Version`(304 不应再次声明
|
||||
实体元信息)。
|
||||
- **404 响应**:当 skill 名合法但资源缺失时返回 `404` 与业务错误码 `20004`,客户端
|
||||
必须将其翻译为本地缓存淘汰并发布"内容缺失"事件,不得视为暂时性错误重试。
|
||||
- **轮询调度**:SDK 必须采用单线程 `schedule + 任务尾端自调度` 模式,使下一次查询的
|
||||
起点为上一次任务的结束时刻而非开始时刻,避免服务端慢响应导致请求堆积。SDK 不应
|
||||
使用 `scheduleAtFixedRate`。
|
||||
- **频率默认值**:默认轮询间隔为 `10000` 毫秒(`AiConstants.DEFAULT_AI_CACHE_UPDATE_INTERVAL`)。
|
||||
首次查询发生在订阅后第一个 interval 之后,订阅本身已同步预热缓存,因此不应再立即
|
||||
发起一次轮询。
|
||||
- **频率可调项**:客户端通过 `Properties` 传入 `nacosAiSkillCacheUpdateInterval`
|
||||
(`AiConstants.AI_SKILL_CACHE_UPDATE_INTERVAL`)覆盖默认值,单位毫秒。该配置仅作用于
|
||||
Skill,与 Prompt、MCP Server、AgentCard 等其他资源的轮询配置相互独立。
|
||||
- **取消语义**:`unsubscribeSkill` 必须取消对应任务并移除 md5 缓存项,且不得继续向服务
|
||||
端发起轮询请求。
|
||||
|
||||
## 7. 待对齐问题
|
||||
|
||||
- upload 时强制执行完整的上游 name 校验规则。
|
||||
|
||||
Reference in New Issue
Block a user