diff --git a/Partner-Framework/pom.xml b/Partner-Framework/pom.xml
index 7aff8c26..b7687a5b 100644
--- a/Partner-Framework/pom.xml
+++ b/Partner-Framework/pom.xml
@@ -73,6 +73,11 @@
kotlinx-coroutines-test
1.10.2
+
+ org.jetbrains.kotlin
+ kotlin-reflect
+ ${kotlin.version}
+
com.openai
openai-java
diff --git a/Partner-Framework/src/main/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenter.kt b/Partner-Framework/src/main/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenter.kt
index 5947ba45..1d7c6e3b 100644
--- a/Partner-Framework/src/main/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenter.kt
+++ b/Partner-Framework/src/main/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenter.kt
@@ -5,12 +5,17 @@ import org.slf4j.LoggerFactory
import work.slhaf.partner.api.agent.runtime.exception.AgentLaunchFailedException
import work.slhaf.partner.api.common.support.DirectoryWatchSupport
import java.io.IOException
+import java.lang.reflect.Field
+import java.lang.reflect.Modifier
import java.nio.charset.StandardCharsets
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.ExecutorService
import java.util.concurrent.Executors
+import kotlin.reflect.KProperty1
+import kotlin.reflect.full.memberProperties
+import kotlin.reflect.jvm.javaField
object ConfigCenter : AutoCloseable {
@@ -86,11 +91,64 @@ object ConfigCenter : AutoCloseable {
if (defaultConfig != null) {
(registration as ConfigRegistration).init(defaultConfig)
}
- throw AgentLaunchFailedException("Failed to init config, related path: $path")
+ val configDoc = resolveConfigDoc(registration.type())
+ throw AgentLaunchFailedException("Failed to init config, related path: $path, config definition: $configDoc")
}
}
+ private fun resolveConfigDoc(type: Class): String {
+ val kotlinProperties = if (type.isKotlinClass()) {
+ type.kotlin.memberProperties.associateBy { property ->
+ property.javaField?.name ?: property.name
+ }
+ } else {
+ emptyMap()
+ }
+ val fieldDocs = type.declaredFields.asSequence()
+ .filterNot(::shouldSkipField)
+ .map { field -> resolveFieldDoc(type, field, kotlinProperties[field.name]) }
+ .toList()
+ return buildString {
+ append("Expected fields:")
+ if (fieldDocs.isNotEmpty()) {
+ append('\n')
+ append(fieldDocs.joinToString("\n\n"))
+ }
+ }
+ }
+
+ private fun resolveFieldDoc(
+ ownerType: Class,
+ field: Field,
+ kotlinProperty: KProperty1?
+ ): String {
+ val configDoc = field.getAnnotation(ConfigDoc::class.java)
+ ?: kotlinProperty?.annotations?.filterIsInstance()?.firstOrNull()
+ val nullableInfo = resolveNullableInfo(ownerType, field, kotlinProperty)
+ val lines = mutableListOf(
+ "- ${field.name}: ${resolveDisplayType(field.type)}",
+ " Description: ${configDoc?.description ?: "No description provided"}"
+ )
+ configDoc?.unit?.takeIf { it.isNotBlank() }?.let { lines += " Unit: $it" }
+ configDoc?.constraint?.takeIf { it.isNotBlank() }?.let { lines += " Constraint: $it" }
+ configDoc?.example?.takeIf { it.isNotBlank() }?.let { lines += " Example: $it" }
+ lines += buildString {
+ append(" Nullable: ")
+ append(nullableInfo.nullable)
+ nullableInfo.note?.let {
+ append(" (")
+ append(it)
+ append(')')
+ }
+ }
+ return lines.joinToString("\n")
+ }
+
+ private fun shouldSkipField(field: Field): Boolean {
+ return field.isSynthetic || Modifier.isStatic(field.modifiers)
+ }
+
private fun handleUpsert(thisDir: Path, context: Path?) {
if (context == null || !Files.isRegularFile(context) || !isJsonFile(context)) {
return
@@ -193,3 +251,15 @@ interface ConfigRegistration {
fun onReload(config: T) {}
fun defaultConfig(): T?
}
+
+@Target(
+ AnnotationTarget.FIELD,
+ AnnotationTarget.VALUE_PARAMETER,
+ AnnotationTarget.PROPERTY
+)
+annotation class ConfigDoc(
+ val description: String,
+ val unit: String = "",
+ val constraint: String = "",
+ val example: String = "",
+)
diff --git a/Partner-Framework/src/main/java/work/slhaf/partner/api/agent/runtime/config/reflect.kt b/Partner-Framework/src/main/java/work/slhaf/partner/api/agent/runtime/config/reflect.kt
new file mode 100644
index 00000000..4239401a
--- /dev/null
+++ b/Partner-Framework/src/main/java/work/slhaf/partner/api/agent/runtime/config/reflect.kt
@@ -0,0 +1,97 @@
+package work.slhaf.partner.api.agent.runtime.config
+
+import net.bytebuddy.jar.asm.*
+import java.lang.reflect.Field
+import kotlin.reflect.KProperty1
+
+internal fun Class<*>.isKotlinClass(): Boolean {
+ return getAnnotation(Metadata::class.java) != null
+}
+
+internal fun resolveDisplayType(type: Class<*>): String {
+ if (type.isArray) {
+ return "${resolveDisplayType(type.componentType)}[]"
+ }
+ return when (type) {
+ java.lang.Integer.TYPE, java.lang.Integer::class.java -> "Int"
+ java.lang.Long.TYPE, java.lang.Long::class.java -> "Long"
+ java.lang.Boolean.TYPE, java.lang.Boolean::class.java -> "Boolean"
+ java.lang.Double.TYPE, java.lang.Double::class.java -> "Double"
+ java.lang.Float.TYPE, java.lang.Float::class.java -> "Float"
+ java.lang.Short.TYPE, java.lang.Short::class.java -> "Short"
+ java.lang.Byte.TYPE, java.lang.Byte::class.java -> "Byte"
+ java.lang.Character.TYPE, java.lang.Character::class.java -> "Char"
+ String::class.java -> "String"
+ else -> type.simpleName
+ }
+}
+
+internal fun resolveNullableInfo(
+ ownerType: Class,
+ field: Field,
+ kotlinProperty: KProperty1?
+): NullableInfo {
+ if (ownerType.isKotlinClass()) {
+ if (kotlinProperty != null) {
+ return NullableInfo(kotlinProperty.returnType.isMarkedNullable, null)
+ }
+ return NullableInfo(false, "inferred because Kotlin property metadata was not found")
+ }
+ val annotationNames = resolveJavaFieldAnnotationNames(ownerType, field)
+ if (annotationNames.any { it.endsWith(".Nullable") || it == "Nullable" }) {
+ return NullableInfo(true, null)
+ }
+ if (annotationNames.any { it.endsWith(".NotNull") || it == "NotNull" }) {
+ return NullableInfo(false, null)
+ }
+ return NullableInfo(false, "inferred from missing nullability annotation, may be unreliable")
+}
+
+private fun resolveJavaFieldAnnotationNames(ownerType: Class, field: Field): Set {
+ val annotationNames = linkedSetOf()
+ annotationNames += (field.annotations.asSequence() + field.annotatedType.annotations.asSequence())
+ .map { it.annotationClass.java.name }
+ .toSet()
+ val resourcePath = "${ownerType.name.replace('.', '/')}.class"
+ val classStream = (ownerType.classLoader?.getResourceAsStream(resourcePath)
+ ?: ClassLoader.getSystemResourceAsStream(resourcePath))
+ ?: return annotationNames
+ classStream.use { input ->
+ ClassReader(input).accept(object : ClassVisitor(Opcodes.ASM9) {
+ override fun visitField(
+ access: Int,
+ name: String,
+ descriptor: String?,
+ signature: String?,
+ value: Any?
+ ): FieldVisitor? {
+ if (name != field.name) {
+ return null
+ }
+ return object : FieldVisitor(Opcodes.ASM9) {
+ override fun visitAnnotation(descriptor: String, visible: Boolean): AnnotationVisitor? {
+ annotationNames += Type.getType(descriptor).className
+ return null
+ }
+
+ override fun visitTypeAnnotation(
+ typeRef: Int,
+ typePath: net.bytebuddy.jar.asm.TypePath?,
+ descriptor: String,
+ visible: Boolean
+ ): AnnotationVisitor? {
+ annotationNames += Type.getType(descriptor).className
+ return null
+ }
+ }
+ }
+ }, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES)
+ }
+ return annotationNames
+}
+
+internal data class NullableInfo(
+ val nullable: Boolean,
+ val note: String?
+)
+
diff --git a/Partner-Framework/src/test/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenterTest.java b/Partner-Framework/src/test/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenterTest.java
index 51ca9dad..5a0aa571 100644
--- a/Partner-Framework/src/test/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenterTest.java
+++ b/Partner-Framework/src/test/java/work/slhaf/partner/api/agent/runtime/config/ConfigCenterTest.java
@@ -251,11 +251,83 @@ class ConfigCenterTest {
Assertions.assertEquals(2, idempotentRegistration.lastConfig().version);
}
+ private static String resolveConfigDoc(Class extends Config> type) throws Exception {
+ var method = java.util.Arrays.stream(ConfigCenter.class.getDeclaredMethods())
+ .filter(candidate -> candidate.getName().startsWith("resolveConfigDoc"))
+ .filter(candidate -> candidate.getParameterCount() == 1)
+ .findFirst()
+ .orElseThrow();
+ method.setAccessible(true);
+ return (String) method.invoke(ConfigCenter.INSTANCE, type);
+ }
+
+ @Test
+ @Order(8)
+ void testResolveConfigDocForJavaConfig() throws Exception {
+ String doc = resolveConfigDoc(JavaDocConfig.class);
+
+ Assertions.assertEquals("""
+ Expected fields:
+ - port: Int
+ Description: WebSocket 监听端口
+ Example: 29600
+ Nullable: false (inferred from missing nullability annotation, may be unreliable)
+
+ - heartbeatInterval: Int
+ Description: 心跳间隔
+ Unit: ms
+ Constraint: > 0
+ Example: 10000
+ Nullable: false (inferred from missing nullability annotation, may be unreliable)
+
+ - tag: String
+ Description: 标签
+ Nullable: true
+ """.stripTrailing(), doc);
+ }
+
+ @Test
+ @Order(9)
+ void testResolveConfigDocForKotlinConfig() throws Exception {
+ String doc = resolveConfigDoc(KotlinDocConfig.class);
+
+ Assertions.assertEquals("""
+ Expected fields:
+ - port: Int
+ Description: WebSocket 监听端口
+ Example: 29600
+ Nullable: false
+
+ - heartbeatInterval: Int
+ Description: 心跳间隔
+ Unit: ms
+ Constraint: > 0
+ Example: 10000
+ Nullable: true
+
+ - tag: String
+ Description: 标签
+ Nullable: true
+ """.stripTrailing(), doc);
+ }
+
public static class TestConfig extends Config {
public String name;
public int version;
}
+ public static class JavaDocConfig extends Config {
+ @ConfigDoc(description = "WebSocket 监听端口", example = "29600")
+ public int port;
+
+ @ConfigDoc(description = "心跳间隔", unit = "ms", constraint = "> 0", example = "10000")
+ public int heartbeatInterval;
+
+ @Nullable
+ @ConfigDoc(description = "标签")
+ public String tag;
+ }
+
private static class TrackingRegistration implements ConfigRegistration {
private final AtomicInteger initCount = new AtomicInteger();
private final AtomicInteger reloadCount = new AtomicInteger();
diff --git a/Partner-Framework/src/test/java/work/slhaf/partner/api/agent/runtime/config/KotlinDocConfig.kt b/Partner-Framework/src/test/java/work/slhaf/partner/api/agent/runtime/config/KotlinDocConfig.kt
new file mode 100644
index 00000000..f495bebe
--- /dev/null
+++ b/Partner-Framework/src/test/java/work/slhaf/partner/api/agent/runtime/config/KotlinDocConfig.kt
@@ -0,0 +1,12 @@
+package work.slhaf.partner.api.agent.runtime.config
+
+class KotlinDocConfig : Config() {
+ @ConfigDoc(description = "WebSocket 监听端口", example = "29600")
+ var port: Int = 29600
+
+ @ConfigDoc(description = "心跳间隔", unit = "ms", constraint = "> 0", example = "10000")
+ var heartbeatInterval: Int? = 10000
+
+ @ConfigDoc(description = "标签")
+ var tag: String? = null
+}