[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:
Sunrisea
2026-05-28 15:40:27 +08:00
committed by GitHub
parent 9136e9864f
commit 0a69ba2430
49 changed files with 3253 additions and 812 deletions
@@ -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;
}
}
@@ -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.
*
@@ -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;
}
}
@@ -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);
@@ -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,
@@ -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;
}
@@ -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;
}
}
@@ -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,
@@ -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();
}
}
@@ -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;
}
}
}
@@ -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) {
}
}
@@ -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);
}
}
}
@@ -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("============================================");
}
}
+19
View File
@@ -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
+65
View File
@@ -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.
+15
View File
@@ -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 校验、签名、
+42
View File
@@ -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 校验规则。