unique = new LinkedHashMap<>();
+ for (SkillSummary skill : skills) {
+ unique.putIfAbsent(skill.name(), skill);
+ }
+ return List.copyOf(unique.values());
+ }
+
+ private static String componentSearchSource(AgentComponent component) {
+ String kind = component.kind().toLowerCase(Locale.ROOT);
+ if (kind.equals("knowledgebase") || kind.equals("knowledge")) {
+ return "knowledge";
+ }
+ if (kind.equals("long_term_memory") || kind.equals("memory")) {
+ return "memory";
+ }
+ return "";
+ }
+
+ public record ToolSummary(String name, String description, String type) {
+ public ToolSummary {
+ name = Objects.requireNonNullElse(name, "");
+ description = Objects.requireNonNullElse(description, "");
+ type = Objects.requireNonNullElse(type, "");
+ }
+ }
+
+ public record SkillSummary(String name, String description) {
+ public SkillSummary {
+ if (name == null || !name.matches("[A-Za-z0-9][A-Za-z0-9_-]{0,127}")) {
+ throw new IllegalArgumentException("Invalid skill name: " + name);
+ }
+ description = Objects.requireNonNullElse(description, "");
+ }
+ }
+}
diff --git a/core/src/main/java/com/volcengine/veadk/agent/SaveSessionPolicy.java b/core/src/main/java/com/volcengine/veadk/agent/SaveSessionPolicy.java
new file mode 100644
index 0000000..40838a3
--- /dev/null
+++ b/core/src/main/java/com/volcengine/veadk/agent/SaveSessionPolicy.java
@@ -0,0 +1,64 @@
+/**
+ * Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+ *
+ * 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.volcengine.veadk.agent;
+
+import java.time.Duration;
+
+/** Controls when completed sessions are copied into long-term memory. */
+public record SaveSessionPolicy(
+ int minNewEvents,
+ Duration minInterval,
+ boolean saveOnSessionSwitch,
+ boolean suppressErrors) {
+
+ public static final int DEFAULT_MIN_NEW_EVENTS = 10;
+ public static final Duration DEFAULT_MIN_INTERVAL = Duration.ofSeconds(60);
+
+ public SaveSessionPolicy {
+ if (minNewEvents < 0) {
+ throw new IllegalArgumentException("minNewEvents must not be negative");
+ }
+ if (minInterval == null || minInterval.isNegative()) {
+ throw new IllegalArgumentException("minInterval must not be negative");
+ }
+ }
+
+ public static SaveSessionPolicy defaults() {
+ return new SaveSessionPolicy(
+ environmentInt("MIN_MESSAGES_THRESHOLD", DEFAULT_MIN_NEW_EVENTS),
+ Duration.ofSeconds(
+ environmentInt(
+ "MIN_TIME_THRESHOLD",
+ Math.toIntExact(DEFAULT_MIN_INTERVAL.toSeconds()))),
+ true,
+ true);
+ }
+
+ /** Policy matching the original Java callback: save every invocation without switch flushing. */
+ public static SaveSessionPolicy legacy() {
+ return new SaveSessionPolicy(0, Duration.ZERO, false, true);
+ }
+
+ private static int environmentInt(String name, int fallback) {
+ String value = System.getenv(name);
+ if (value == null || value.isBlank()) {
+ return fallback;
+ }
+ try {
+ return Integer.parseInt(value.trim());
+ } catch (NumberFormatException exception) {
+ return fallback;
+ }
+ }
+}
diff --git a/core/src/main/java/com/volcengine/veadk/agent/SaveSessionToMemoryCallback.java b/core/src/main/java/com/volcengine/veadk/agent/SaveSessionToMemoryCallback.java
index dd3e7ef..c02f459 100644
--- a/core/src/main/java/com/volcengine/veadk/agent/SaveSessionToMemoryCallback.java
+++ b/core/src/main/java/com/volcengine/veadk/agent/SaveSessionToMemoryCallback.java
@@ -18,9 +18,18 @@
import com.google.adk.agents.CallbackContext;
import com.google.adk.agents.Callbacks;
import com.google.adk.agents.InvocationContext;
+import com.google.adk.memory.BaseMemoryService;
+import com.google.adk.sessions.BaseSessionService;
+import com.google.adk.sessions.Session;
import com.google.genai.types.Content;
import com.volcengine.veadk.utils.ReadonlyContextAccessorUtil;
+import io.reactivex.rxjava3.core.Completable;
import io.reactivex.rxjava3.core.Maybe;
+import io.reactivex.rxjava3.subjects.CompletableSubject;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentMap;
+import java.util.function.LongSupplier;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -28,16 +37,191 @@ public class SaveSessionToMemoryCallback implements Callbacks.AfterAgentCallback
private static final Logger log = LoggerFactory.getLogger(SaveSessionToMemoryCallback.class);
+ private final SaveSessionPolicy policy;
+ private final LongSupplier currentTimeMillis;
+ private final BaseMemoryService memoryServiceOverride;
+ private final boolean legacyFireAndForget;
+ private final ConcurrentMap saveStates = new ConcurrentHashMap<>();
+ private final ConcurrentMap activeSessions = new ConcurrentHashMap<>();
+ private final ConcurrentMap saveQueues = new ConcurrentHashMap<>();
+
+ public SaveSessionToMemoryCallback() {
+ this(SaveSessionPolicy.legacy(), System::currentTimeMillis, null, true);
+ }
+
+ public SaveSessionToMemoryCallback(SaveSessionPolicy policy) {
+ this(policy, System::currentTimeMillis, null, false);
+ }
+
+ SaveSessionToMemoryCallback(SaveSessionPolicy policy, LongSupplier currentTimeMillis) {
+ this(policy, currentTimeMillis, null, false);
+ }
+
+ SaveSessionToMemoryCallback(SaveSessionPolicy policy, BaseMemoryService memoryServiceOverride) {
+ this(policy, System::currentTimeMillis, memoryServiceOverride, false);
+ }
+
+ private SaveSessionToMemoryCallback(
+ SaveSessionPolicy policy,
+ LongSupplier currentTimeMillis,
+ BaseMemoryService memoryServiceOverride,
+ boolean legacyFireAndForget) {
+ this.policy = Objects.requireNonNull(policy, "policy");
+ this.currentTimeMillis = Objects.requireNonNull(currentTimeMillis, "currentTimeMillis");
+ this.memoryServiceOverride = memoryServiceOverride;
+ this.legacyFireAndForget = legacyFireAndForget;
+ }
+
+ public SaveSessionPolicy policy() {
+ return policy;
+ }
+
@Override
public Maybe call(CallbackContext callbackContext) {
- InvocationContext invocationContext =
+ if (legacyFireAndForget) {
+ return legacyCall(callbackContext);
+ }
+ Completable save =
+ Completable.defer(
+ () ->
+ save(
+ ReadonlyContextAccessorUtil.getInvocationContext(
+ callbackContext)));
+ if (policy.suppressErrors()) {
+ save =
+ save.onErrorComplete(
+ error -> {
+ log.error("Failed to save session", error);
+ return true;
+ });
+ }
+ return save.andThen(Maybe.empty());
+ }
+
+ private Maybe legacyCall(CallbackContext callbackContext) {
+ InvocationContext context =
ReadonlyContextAccessorUtil.getInvocationContext(callbackContext);
- invocationContext
- .memoryService()
- .addSessionToMemory(invocationContext.session())
+ memoryService(context)
+ .addSessionToMemory(context.session())
.subscribe(
- () -> log.info("Saved session {}", invocationContext.session().id()),
- err -> log.error("Failed to save session", err));
+ () -> log.info("Saved session {}", context.session().id()),
+ error -> log.error("Failed to save session", error));
return Maybe.empty();
}
+
+ private Completable save(InvocationContext context) {
+ UserKey userKey = new UserKey(context.appName(), context.userId());
+ return Completable.defer(
+ () -> {
+ CompletableSubject completion = CompletableSubject.create();
+ CompletableSubject previous = saveQueues.put(userKey, completion);
+ Completable turn = previous == null ? Completable.complete() : previous;
+ return turn.andThen(saveOne(context, userKey))
+ .doFinally(
+ () -> {
+ completion.onComplete();
+ saveQueues.remove(userKey, completion);
+ });
+ });
+ }
+
+ private Completable saveOne(InvocationContext context, UserKey userKey) {
+ return Completable.defer(
+ () -> {
+ Session currentSession = context.session();
+ long now = currentTimeMillis.getAsLong();
+ String previousSessionId = activeSessions.get(userKey);
+ Completable previousSave =
+ savePreviousSessionIfSwitched(
+ context, previousSessionId, currentSession.id(), now);
+ return previousSave.andThen(
+ Completable.defer(
+ () -> {
+ activeSessions.put(userKey, currentSession.id());
+ return saveCurrentSession(context, now);
+ }));
+ });
+ }
+
+ private Completable savePreviousSessionIfSwitched(
+ InvocationContext context,
+ String previousSessionId,
+ String currentSessionId,
+ long now) {
+ if (!policy.saveOnSessionSwitch()
+ || previousSessionId == null
+ || previousSessionId.equals(currentSessionId)) {
+ return Completable.complete();
+ }
+ BaseSessionService sessions = context.sessionService();
+ BaseMemoryService memory = memoryService(context);
+ return sessions.getSession(
+ context.appName(),
+ context.userId(),
+ previousSessionId,
+ java.util.Optional.empty())
+ .flatMapCompletable(
+ session ->
+ memory.addSessionToMemory(session)
+ .doOnComplete(
+ () -> {
+ saveStates.put(
+ new SessionKey(
+ context.appName(),
+ context.userId(),
+ previousSessionId),
+ new SaveState(
+ now, session.events().size()));
+ log.info(
+ "Saved previous session {} after"
+ + " session switch",
+ previousSessionId);
+ }));
+ }
+
+ private Completable saveCurrentSession(InvocationContext context, long now) {
+ String sessionId = context.session().id();
+ SessionKey key = new SessionKey(context.appName(), context.userId(), sessionId);
+ return context.sessionService()
+ .getSession(
+ context.appName(), context.userId(), sessionId, java.util.Optional.empty())
+ .switchIfEmpty(
+ Maybe.error(
+ new IllegalStateException(
+ "Session not found in session service: " + sessionId)))
+ .flatMapCompletable(
+ session -> {
+ int eventCount = session.events().size();
+ SaveState previous = saveStates.get(key);
+ if (shouldSkip(previous, eventCount, now)) {
+ return Completable.complete();
+ }
+ return memoryService(context)
+ .addSessionToMemory(session)
+ .doOnComplete(
+ () -> {
+ saveStates.put(key, new SaveState(now, eventCount));
+ log.info("Saved session {}", sessionId);
+ });
+ });
+ }
+
+ private BaseMemoryService memoryService(InvocationContext context) {
+ return memoryServiceOverride == null ? context.memoryService() : memoryServiceOverride;
+ }
+
+ private boolean shouldSkip(SaveState previous, int currentEventCount, long now) {
+ if (previous == null) {
+ return false;
+ }
+ long elapsed = now - previous.savedAtMillis();
+ int newEvents = currentEventCount - previous.eventCount();
+ return elapsed < policy.minInterval().toMillis() && newEvents < policy.minNewEvents();
+ }
+
+ private record UserKey(String appName, String userId) {}
+
+ private record SessionKey(String appName, String userId, String sessionId) {}
+
+ private record SaveState(long savedAtMillis, int eventCount) {}
}
diff --git a/core/src/main/java/com/volcengine/veadk/config/ConfigLoader.java b/core/src/main/java/com/volcengine/veadk/config/ConfigLoader.java
new file mode 100644
index 0000000..5a599fc
--- /dev/null
+++ b/core/src/main/java/com/volcengine/veadk/config/ConfigLoader.java
@@ -0,0 +1,159 @@
+/**
+ * Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+ *
+ * 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.volcengine.veadk.config;
+
+import java.io.IOException;
+import java.io.Reader;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import org.yaml.snakeyaml.LoaderOptions;
+import org.yaml.snakeyaml.Yaml;
+import org.yaml.snakeyaml.constructor.SafeConstructor;
+
+final class ConfigLoader {
+
+ private ConfigLoader() {}
+
+ static Map load(Path workingDirectory, Map environment) {
+ Path directory = workingDirectory.toAbsolutePath().normalize();
+ Map values = new LinkedHashMap<>();
+
+ findUpwards(directory, "config.yaml").ifPresent(path -> values.putAll(loadYaml(path)));
+ Path dotenv = directory.resolve(".env");
+ if (Files.isRegularFile(dotenv)) {
+ values.putAll(loadDotenv(dotenv));
+ }
+ environment.forEach((key, value) -> values.put(normalize(key), value));
+ applyProviderAliases(values);
+ return values;
+ }
+
+ private static java.util.Optional findUpwards(Path directory, String filename) {
+ Path current = directory;
+ while (current != null) {
+ Path candidate = current.resolve(filename);
+ if (Files.isRegularFile(candidate)) {
+ return java.util.Optional.of(candidate);
+ }
+ current = current.getParent();
+ }
+ return java.util.Optional.empty();
+ }
+
+ private static Map loadYaml(Path path) {
+ LoaderOptions options = new LoaderOptions();
+ options.setAllowDuplicateKeys(false);
+ Yaml yaml = new Yaml(new SafeConstructor(options));
+ try (Reader reader = Files.newBufferedReader(path, StandardCharsets.UTF_8)) {
+ Object root = yaml.load(reader);
+ Map flattened = new LinkedHashMap<>();
+ if (root instanceof Map, ?> map) {
+ flatten(map, "", flattened);
+ }
+ return flattened;
+ } catch (IOException | RuntimeException exception) {
+ throw new IllegalArgumentException("Unable to load VeADK config: " + path, exception);
+ }
+ }
+
+ private static void flatten(Map, ?> source, String prefix, Map target) {
+ source.forEach(
+ (rawKey, value) -> {
+ String key = String.valueOf(rawKey);
+ String path = prefix.isEmpty() ? key : prefix + "_" + key;
+ if (value instanceof Map, ?> nested) {
+ flatten(nested, path, target);
+ } else {
+ target.put(normalize(path), stringify(value));
+ }
+ });
+ }
+
+ private static String stringify(Object value) {
+ if (value == null) {
+ return "";
+ }
+ if (value instanceof List> list) {
+ return list.stream()
+ .map(String::valueOf)
+ .reduce((left, right) -> left + "," + right)
+ .orElse("");
+ }
+ return String.valueOf(value);
+ }
+
+ private static Map loadDotenv(Path path) {
+ Map values = new LinkedHashMap<>();
+ try {
+ for (String rawLine : Files.readAllLines(path, StandardCharsets.UTF_8)) {
+ String line = rawLine.trim();
+ if (line.isEmpty() || line.startsWith("#")) {
+ continue;
+ }
+ if (line.startsWith("export ")) {
+ line = line.substring("export ".length()).trim();
+ }
+ int separator = line.indexOf('=');
+ if (separator <= 0) {
+ continue;
+ }
+ String key = normalize(line.substring(0, separator).trim());
+ String value = unquote(line.substring(separator + 1).trim());
+ values.put(key, value);
+ }
+ return values;
+ } catch (IOException exception) {
+ throw new IllegalArgumentException("Unable to load VeADK dotenv: " + path, exception);
+ }
+ }
+
+ private static String unquote(String value) {
+ if (value.length() >= 2) {
+ char first = value.charAt(0);
+ char last = value.charAt(value.length() - 1);
+ if ((first == '\'' && last == '\'') || (first == '"' && last == '"')) {
+ return value.substring(1, value.length() - 1);
+ }
+ }
+ return value;
+ }
+
+ private static void applyProviderAliases(Map values) {
+ if (!"byteplus".equalsIgnoreCase(values.getOrDefault("CLOUD_PROVIDER", ""))) {
+ return;
+ }
+ copyIfAbsent(values, "BYTEPLUS_ACCESS_KEY", "VOLCENGINE_ACCESS_KEY");
+ copyIfAbsent(values, "BYTEPLUS_SECRET_KEY", "VOLCENGINE_SECRET_KEY");
+ }
+
+ private static void copyIfAbsent(
+ Map values, String source, String destination) {
+ String existing = values.get(destination);
+ String alias = values.get(source);
+ if ((existing == null || existing.isBlank()) && alias != null && !alias.isBlank()) {
+ values.put(destination, alias);
+ }
+ }
+
+ static String normalize(String key) {
+ return key.trim().toUpperCase(Locale.ROOT).replace('.', '_').replace('-', '_');
+ }
+}
diff --git a/core/src/main/java/com/volcengine/veadk/config/VeADKConfig.java b/core/src/main/java/com/volcengine/veadk/config/VeADKConfig.java
new file mode 100644
index 0000000..6ec4ca6
--- /dev/null
+++ b/core/src/main/java/com/volcengine/veadk/config/VeADKConfig.java
@@ -0,0 +1,207 @@
+/**
+ * Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+ *
+ * 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.volcengine.veadk.config;
+
+import java.nio.file.Path;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Set;
+
+/** Loads VeADK settings from {@code config.yaml}, {@code .env}, and process environment. */
+public final class VeADKConfig {
+
+ public static final String DEFAULT_MODEL_NAME = "doubao-seed-2-1-pro-260628";
+ public static final String DEFAULT_MODEL_PROVIDER = "openai";
+ public static final String DEFAULT_MODEL_API_BASE = "https://ark.cn-beijing.volces.com/api/v3/";
+ public static final String DEFAULT_EMBEDDING_MODEL = "doubao-embedding-vision-250615";
+ public static final int DEFAULT_EMBEDDING_DIMENSION = 2048;
+
+ private static final Set SECRET_MARKERS =
+ Set.of("API_KEY", "SECRET", "PASSWORD", "TOKEN", "CREDENTIAL");
+
+ private final Map values;
+
+ private VeADKConfig(Map values) {
+ this.values = Collections.unmodifiableMap(new LinkedHashMap<>(values));
+ }
+
+ public static VeADKConfig load() {
+ return load(Path.of(""));
+ }
+
+ public static VeADKConfig load(Path workingDirectory) {
+ return load(workingDirectory, System.getenv());
+ }
+
+ /** Loads with an explicit environment map, useful for embedded runtimes and deterministic tests. */
+ public static VeADKConfig load(Path workingDirectory, Map environment) {
+ return new VeADKConfig(ConfigLoader.load(workingDirectory, environment));
+ }
+
+ public static VeADKConfig from(Map values) {
+ Map normalized = new LinkedHashMap<>();
+ values.forEach((key, value) -> normalized.put(ConfigLoader.normalize(key), value));
+ return new VeADKConfig(normalized);
+ }
+
+ public Optional get(String key) {
+ return Optional.ofNullable(values.get(ConfigLoader.normalize(key)));
+ }
+
+ public String get(String key, String defaultValue) {
+ String value = values.get(ConfigLoader.normalize(key));
+ return value == null || value.isBlank() ? defaultValue : value;
+ }
+
+ public String require(String key) {
+ String normalized = ConfigLoader.normalize(key);
+ String value = values.get(normalized);
+ if (value == null || value.isBlank()) {
+ throw new IllegalStateException(
+ "Missing required configuration: "
+ + normalized
+ + ". Configure it in the environment, .env, or config.yaml.");
+ }
+ return value;
+ }
+
+ public boolean getBoolean(String key, boolean defaultValue) {
+ String value = values.get(ConfigLoader.normalize(key));
+ if (value == null || value.isBlank()) {
+ return defaultValue;
+ }
+ return switch (value.trim().toLowerCase(Locale.ROOT)) {
+ case "true", "1", "yes", "on", "enabled" -> true;
+ case "false", "0", "no", "off", "disabled" -> false;
+ default ->
+ throw new IllegalArgumentException(
+ "Configuration " + ConfigLoader.normalize(key) + " must be a boolean");
+ };
+ }
+
+ public int getInt(String key, int defaultValue) {
+ String value = values.get(ConfigLoader.normalize(key));
+ if (value == null || value.isBlank()) {
+ return defaultValue;
+ }
+ try {
+ return Integer.parseInt(value.trim());
+ } catch (NumberFormatException exception) {
+ throw new IllegalArgumentException(
+ "Configuration " + ConfigLoader.normalize(key) + " must be an integer",
+ exception);
+ }
+ }
+
+ public ModelConfig model() {
+ boolean byteplus = "byteplus".equalsIgnoreCase(get("CLOUD_PROVIDER", ""));
+ String defaultName = byteplus ? "seed-2-0-lite-260228" : DEFAULT_MODEL_NAME;
+ String defaultBase =
+ byteplus
+ ? "https://ark.ap-southeast.bytepluses.com/api/v3"
+ : DEFAULT_MODEL_API_BASE;
+ return new ModelConfig(
+ get("MODEL_AGENT_NAME", defaultName),
+ get("MODEL_AGENT_PROVIDER", DEFAULT_MODEL_PROVIDER),
+ get("MODEL_AGENT_API_BASE", defaultBase),
+ get("MODEL_AGENT_API_KEY", ""),
+ get("MODEL_AGENT_API_KEY_NAME", ""));
+ }
+
+ public EmbeddingConfig embedding() {
+ boolean byteplus = "byteplus".equalsIgnoreCase(get("CLOUD_PROVIDER", ""));
+ return new EmbeddingConfig(
+ get(
+ "MODEL_EMBEDDING_NAME",
+ byteplus ? "skylark-embedding-vision-250615" : DEFAULT_EMBEDDING_MODEL),
+ getInt("MODEL_EMBEDDING_DIM", DEFAULT_EMBEDDING_DIMENSION),
+ get("MODEL_EMBEDDING_API_BASE", model().apiBase()),
+ get("MODEL_EMBEDDING_API_KEY", model().apiKey()));
+ }
+
+ public OpenTelemetryConfig openTelemetry() {
+ return new OpenTelemetryConfig(
+ getBoolean("OBSERVABILITY_OPENTELEMETRY_TRACE_CONTENT", true),
+ get(
+ "OBSERVABILITY_OPENTELEMETRY_TLS_ENDPOINT",
+ "https://tls-cn-beijing.volces.com:4317"),
+ get("OBSERVABILITY_OPENTELEMETRY_TLS_REGION", "cn-beijing"),
+ get("OBSERVABILITY_OPENTELEMETRY_TLS_SERVICE_NAME", ""));
+ }
+
+ /** Returns all resolved values. Prefer {@link #redactedValues()} for diagnostics. */
+ public Map values() {
+ return values;
+ }
+
+ public Map redactedValues() {
+ Map redacted = new LinkedHashMap<>();
+ values.forEach(
+ (key, value) ->
+ redacted.put(
+ key, isSecret(key) && !value.isBlank() ? "" : value));
+ return Collections.unmodifiableMap(redacted);
+ }
+
+ private static boolean isSecret(String key) {
+ String upper = key.toUpperCase(Locale.ROOT);
+ return SECRET_MARKERS.stream().anyMatch(upper::contains);
+ }
+
+ @Override
+ public String toString() {
+ return "VeADKConfig" + redactedValues();
+ }
+
+ public record ModelConfig(
+ String name, String provider, String apiBase, String apiKey, String apiKeyName) {
+ @Override
+ public String toString() {
+ return "ModelConfig[name="
+ + name
+ + ", provider="
+ + provider
+ + ", apiBase="
+ + apiBase
+ + ", apiKey="
+ + (apiKey.isBlank() ? "" : "")
+ + ", apiKeyName="
+ + apiKeyName
+ + "]";
+ }
+ }
+
+ public record EmbeddingConfig(String name, int dimension, String apiBase, String apiKey) {
+ @Override
+ public String toString() {
+ return "EmbeddingConfig[name="
+ + name
+ + ", dimension="
+ + dimension
+ + ", apiBase="
+ + apiBase
+ + ", apiKey="
+ + (apiKey.isBlank() ? "" : "")
+ + "]";
+ }
+ }
+
+ public record OpenTelemetryConfig(
+ boolean traceContent, String tlsEndpoint, String tlsRegion, String tlsServiceName) {}
+}
diff --git a/core/src/main/java/com/volcengine/veadk/knowledgebase/InMemoryKnowledgebaseBackend.java b/core/src/main/java/com/volcengine/veadk/knowledgebase/InMemoryKnowledgebaseBackend.java
new file mode 100644
index 0000000..bdd4780
--- /dev/null
+++ b/core/src/main/java/com/volcengine/veadk/knowledgebase/InMemoryKnowledgebaseBackend.java
@@ -0,0 +1,84 @@
+/**
+ * Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+ *
+ * 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.volcengine.veadk.knowledgebase;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/** Deterministic in-memory backend suitable for local development and tests. */
+public final class InMemoryKnowledgebaseBackend implements KnowledgebaseBackend {
+
+ private final String index;
+ private final CopyOnWriteArrayList entries = new CopyOnWriteArrayList<>();
+
+ public InMemoryKnowledgebaseBackend(String index) {
+ if (index == null || index.isBlank()) {
+ throw new IllegalArgumentException("index must not be blank");
+ }
+ this.index = index;
+ }
+
+ @Override
+ public String index() {
+ return index;
+ }
+
+ @Override
+ public boolean add(List entries) {
+ this.entries.addAll(List.copyOf(entries));
+ return true;
+ }
+
+ @Override
+ public List search(String query, int topK) {
+ if (topK <= 0) {
+ throw new IllegalArgumentException("topK must be positive");
+ }
+ Set queryTerms = terms(query);
+ List ranked = new ArrayList<>(entries.size());
+ for (int index = 0; index < entries.size(); index++) {
+ KnowledgebaseEntry entry = entries.get(index);
+ Set contentTerms = terms(entry.content());
+ long overlap = queryTerms.stream().filter(contentTerms::contains).count();
+ ranked.add(new RankedEntry(entry, overlap, index));
+ }
+ ranked.sort(
+ Comparator.comparingLong(RankedEntry::score)
+ .reversed()
+ .thenComparingInt(RankedEntry::insertionOrder));
+ return ranked.stream().limit(topK).map(RankedEntry::entry).toList();
+ }
+
+ private static Set terms(String text) {
+ Set terms = new HashSet<>();
+ if (text == null) {
+ return terms;
+ }
+ for (String term : text.toLowerCase(Locale.ROOT).split("[^\\p{L}\\p{N}_]+")) {
+ if (!term.isBlank()) {
+ terms.add(term);
+ }
+ }
+ return terms;
+ }
+
+ private record RankedEntry(KnowledgebaseEntry entry, long score, int insertionOrder) {}
+}
diff --git a/core/src/main/java/com/volcengine/veadk/knowledgebase/KnowledgeBase.java b/core/src/main/java/com/volcengine/veadk/knowledgebase/KnowledgeBase.java
new file mode 100644
index 0000000..97a0379
--- /dev/null
+++ b/core/src/main/java/com/volcengine/veadk/knowledgebase/KnowledgeBase.java
@@ -0,0 +1,157 @@
+/**
+ * Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+ *
+ * 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.volcengine.veadk.knowledgebase;
+
+import io.reactivex.rxjava3.core.Single;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/** Backend-neutral knowledge-base facade aligned with the Python VeADK contract. */
+public final class KnowledgeBase implements BaseKnowledgebaseService, AutoCloseable {
+
+ public static final String DEFAULT_NAME = "user_knowledgebase";
+ public static final String DEFAULT_DESCRIPTION =
+ "This knowledgebase stores some user-related information.";
+
+ private final String name;
+ private final String description;
+ private final KnowledgebaseBackend backend;
+ private final int topK;
+
+ public KnowledgeBase(String index) {
+ this(new InMemoryKnowledgebaseBackend(index), DEFAULT_NAME, DEFAULT_DESCRIPTION, 10);
+ }
+
+ public KnowledgeBase(KnowledgebaseBackend backend) {
+ this(backend, DEFAULT_NAME, DEFAULT_DESCRIPTION, 10);
+ }
+
+ public KnowledgeBase(KnowledgebaseBackend backend, String name, String description, int topK) {
+ this.backend = Objects.requireNonNull(backend, "backend");
+ this.name = requireText(name, "name");
+ this.description = Objects.requireNonNullElse(description, "");
+ if (topK <= 0) {
+ throw new IllegalArgumentException("topK must be positive");
+ }
+ this.topK = topK;
+ }
+
+ public String name() {
+ return name;
+ }
+
+ public String description() {
+ return description;
+ }
+
+ public String index() {
+ return backend.index();
+ }
+
+ public String backend() {
+ return backend.backendName();
+ }
+
+ public int topK() {
+ return topK;
+ }
+
+ public boolean addFromText(String text) {
+ return addFromText(List.of(text));
+ }
+
+ public boolean addFromText(List texts) {
+ return backend.add(texts.stream().map(KnowledgebaseEntry::new).toList());
+ }
+
+ public boolean addFromFiles(List files) {
+ List entries = new ArrayList<>();
+ for (Path file : files) {
+ try {
+ entries.add(
+ new KnowledgebaseEntry(
+ Files.readString(file, StandardCharsets.UTF_8),
+ Map.of("file_path", file.toAbsolutePath().normalize().toString())));
+ } catch (IOException exception) {
+ throw new IllegalArgumentException(
+ "Unable to read knowledge file: " + file, exception);
+ }
+ }
+ return backend.add(entries);
+ }
+
+ public boolean addFromDirectory(Path directory) {
+ if (!Files.isDirectory(directory)) {
+ throw new IllegalArgumentException("Knowledge directory does not exist: " + directory);
+ }
+ try (java.util.stream.Stream paths = Files.walk(directory)) {
+ return addFromFiles(paths.filter(Files::isRegularFile).sorted().toList());
+ } catch (IOException exception) {
+ throw new IllegalArgumentException(
+ "Unable to read knowledge directory: " + directory, exception);
+ }
+ }
+
+ public List search(String query) {
+ return search(query, topK);
+ }
+
+ public List search(String query, int topK) {
+ return List.copyOf(backend.search(Objects.requireNonNull(query, "query"), topK));
+ }
+
+ @Override
+ public Single searchKnowledgebase(String query) {
+ return Single.fromCallable(
+ () -> {
+ List
+ legacyEntries =
+ search(query).stream()
+ .map(KnowledgeBase::toLegacyEntry)
+ .toList();
+ SearchKnowledgebaseResponse response = new SearchKnowledgebaseResponse();
+ response.setKnowledgebaseEntries(legacyEntries);
+ return response;
+ });
+ }
+
+ @Override
+ public void close() {
+ backend.close();
+ }
+
+ private static com.volcengine.veadk.integration.vikingknowledgebase.KnowledgebaseEntry
+ toLegacyEntry(KnowledgebaseEntry entry) {
+ Map metadata = new LinkedHashMap<>();
+ entry.metadata().forEach((key, value) -> metadata.put(key, String.valueOf(value)));
+ return new com.volcengine.veadk.integration.vikingknowledgebase.KnowledgebaseEntry(
+ entry.content(), metadata);
+ }
+
+ private static String requireText(String value, String field) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(field + " must not be blank");
+ }
+ return value;
+ }
+}
diff --git a/core/src/main/java/com/volcengine/veadk/knowledgebase/KnowledgebaseBackend.java b/core/src/main/java/com/volcengine/veadk/knowledgebase/KnowledgebaseBackend.java
new file mode 100644
index 0000000..44e896b
--- /dev/null
+++ b/core/src/main/java/com/volcengine/veadk/knowledgebase/KnowledgebaseBackend.java
@@ -0,0 +1,35 @@
+/**
+ * Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+ *
+ * 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.volcengine.veadk.knowledgebase;
+
+import java.util.List;
+
+/** Storage and retrieval contract implemented by all knowledge-base backends. */
+public interface KnowledgebaseBackend extends AutoCloseable {
+
+ String index();
+
+ boolean add(List entries);
+
+ List search(String query, int topK);
+
+ default String backendName() {
+ return getClass().getSimpleName();
+ }
+
+ @Override
+ default void close() {}
+}
diff --git a/core/src/main/java/com/volcengine/veadk/knowledgebase/KnowledgebaseEntry.java b/core/src/main/java/com/volcengine/veadk/knowledgebase/KnowledgebaseEntry.java
new file mode 100644
index 0000000..14e3e5b
--- /dev/null
+++ b/core/src/main/java/com/volcengine/veadk/knowledgebase/KnowledgebaseEntry.java
@@ -0,0 +1,33 @@
+/**
+ * Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+ *
+ * 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.volcengine.veadk.knowledgebase;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Objects;
+
+/** Backend-neutral knowledge-base entry. */
+public record KnowledgebaseEntry(String content, Map metadata) {
+
+ public KnowledgebaseEntry {
+ content = Objects.requireNonNull(content, "content");
+ metadata = Map.copyOf(new LinkedHashMap<>(Objects.requireNonNullElse(metadata, Map.of())));
+ }
+
+ public KnowledgebaseEntry(String content) {
+ this(content, Map.of());
+ }
+}
diff --git a/core/src/main/java/com/volcengine/veadk/knowledgebase/KnowledgebaseServiceBackendAdapter.java b/core/src/main/java/com/volcengine/veadk/knowledgebase/KnowledgebaseServiceBackendAdapter.java
new file mode 100644
index 0000000..d4d98d5
--- /dev/null
+++ b/core/src/main/java/com/volcengine/veadk/knowledgebase/KnowledgebaseServiceBackendAdapter.java
@@ -0,0 +1,63 @@
+/**
+ * Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+ *
+ * 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.volcengine.veadk.knowledgebase;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+
+/** Read-only adapter that exposes an existing Java knowledge-base service as a new backend. */
+public final class KnowledgebaseServiceBackendAdapter implements KnowledgebaseBackend {
+
+ private final String index;
+ private final BaseKnowledgebaseService service;
+
+ public KnowledgebaseServiceBackendAdapter(String index, BaseKnowledgebaseService service) {
+ if (index == null || index.isBlank()) {
+ throw new IllegalArgumentException("index must not be blank");
+ }
+ this.index = index;
+ this.service = Objects.requireNonNull(service, "service");
+ }
+
+ @Override
+ public String index() {
+ return index;
+ }
+
+ @Override
+ public boolean add(List entries) {
+ throw new UnsupportedOperationException("The legacy knowledge-base service is read-only");
+ }
+
+ @Override
+ public List search(String query, int topK) {
+ SearchKnowledgebaseResponse response = service.searchKnowledgebase(query).blockingGet();
+ if (response == null || response.getKnowledgebaseEntries() == null) {
+ return List.of();
+ }
+ return response.getKnowledgebaseEntries().stream()
+ .limit(topK)
+ .map(
+ entry ->
+ new KnowledgebaseEntry(
+ entry.getContent(),
+ entry.getMetadata() == null
+ ? Map.of()
+ : Map.copyOf(entry.getMetadata())))
+ .toList();
+ }
+}
diff --git a/core/src/main/java/com/volcengine/veadk/memory/InMemoryLongTermMemoryBackend.java b/core/src/main/java/com/volcengine/veadk/memory/InMemoryLongTermMemoryBackend.java
new file mode 100644
index 0000000..f796199
--- /dev/null
+++ b/core/src/main/java/com/volcengine/veadk/memory/InMemoryLongTermMemoryBackend.java
@@ -0,0 +1,109 @@
+/**
+ * Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+ *
+ * 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.volcengine.veadk.memory;
+
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/** Deterministic local backend suitable for development and tests. */
+public final class InMemoryLongTermMemoryBackend implements LongTermMemoryBackend {
+
+ private final String index;
+ private final Map> memoriesByUser =
+ new ConcurrentHashMap<>();
+
+ public InMemoryLongTermMemoryBackend(String index) {
+ if (index == null || index.isBlank()) {
+ throw new IllegalArgumentException("index must not be blank");
+ }
+ this.index = index;
+ }
+
+ @Override
+ public String index() {
+ return index;
+ }
+
+ @Override
+ public boolean saveMemory(
+ String userId, List eventStrings, Map options) {
+ String normalizedUserId = requireText(userId, "userId");
+ List events = List.copyOf(eventStrings);
+ if (events.stream().anyMatch(event -> event == null || event.isBlank())) {
+ throw new IllegalArgumentException("eventStrings must not contain blank values");
+ }
+ memoriesByUser
+ .computeIfAbsent(normalizedUserId, ignored -> new CopyOnWriteArrayList<>())
+ .addAll(events);
+ return true;
+ }
+
+ @Override
+ public List searchMemory(
+ String userId, String query, int topK, Map options) {
+ if (topK <= 0) {
+ throw new IllegalArgumentException("topK must be positive");
+ }
+ requireText(query, "query");
+ List memories =
+ memoriesByUser.getOrDefault(
+ requireText(userId, "userId"), new CopyOnWriteArrayList<>());
+ Set queryTerms = terms(query);
+ List ranked = new ArrayList<>(memories.size());
+ for (int position = 0; position < memories.size(); position++) {
+ String memory = memories.get(position);
+ Set memoryTerms = terms(memory);
+ long overlap = queryTerms.stream().filter(memoryTerms::contains).count();
+ if (memory.toLowerCase(Locale.ROOT).contains(query.toLowerCase(Locale.ROOT))) {
+ overlap++;
+ }
+ ranked.add(new RankedMemory(memory, overlap, position));
+ }
+ ranked.sort(
+ Comparator.comparingLong(RankedMemory::score)
+ .reversed()
+ .thenComparingInt(RankedMemory::insertionOrder));
+ return ranked.stream().limit(topK).map(RankedMemory::value).toList();
+ }
+
+ public int size(String userId) {
+ return memoriesByUser.getOrDefault(userId, new CopyOnWriteArrayList<>()).size();
+ }
+
+ private static Set terms(String text) {
+ Set terms = new HashSet<>();
+ for (String term : text.toLowerCase(Locale.ROOT).split("[^\\p{L}\\p{N}_]+")) {
+ if (!term.isBlank()) {
+ terms.add(term);
+ }
+ }
+ return terms;
+ }
+
+ private static String requireText(String value, String field) {
+ if (value == null || value.isBlank()) {
+ throw new IllegalArgumentException(field + " must not be blank");
+ }
+ return value;
+ }
+
+ private record RankedMemory(String value, long score, int insertionOrder) {}
+}
diff --git a/core/src/main/java/com/volcengine/veadk/memory/InMemoryShortTermMemoryBackend.java b/core/src/main/java/com/volcengine/veadk/memory/InMemoryShortTermMemoryBackend.java
new file mode 100644
index 0000000..6189c6b
--- /dev/null
+++ b/core/src/main/java/com/volcengine/veadk/memory/InMemoryShortTermMemoryBackend.java
@@ -0,0 +1,28 @@
+/**
+ * Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+ *
+ * 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.volcengine.veadk.memory;
+
+import com.google.adk.sessions.BaseSessionService;
+import com.google.adk.sessions.InMemorySessionService;
+
+/** Local backend that keeps sessions in process memory. */
+public final class InMemoryShortTermMemoryBackend implements ShortTermMemoryBackend {
+
+ private final InMemorySessionService sessionService = new InMemorySessionService();
+
+ @Override
+ public BaseSessionService sessionService() {
+ return sessionService;
+ }
+}
diff --git a/core/src/main/java/com/volcengine/veadk/memory/LongTermMemory.java b/core/src/main/java/com/volcengine/veadk/memory/LongTermMemory.java
new file mode 100644
index 0000000..d4ecc74
--- /dev/null
+++ b/core/src/main/java/com/volcengine/veadk/memory/LongTermMemory.java
@@ -0,0 +1,244 @@
+/**
+ * Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates.
+ *
+ *
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.volcengine.veadk.memory;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.google.adk.events.Event;
+import com.google.adk.memory.BaseMemoryService;
+import com.google.adk.memory.MemoryEntry;
+import com.google.adk.memory.SearchMemoryResponse;
+import com.google.adk.sessions.Session;
+import com.google.genai.types.Content;
+import com.google.genai.types.Part;
+import com.volcengine.veadk.utils.JSONUtil;
+import io.reactivex.rxjava3.core.Completable;
+import io.reactivex.rxjava3.core.Single;
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/** Backend-neutral long-term-memory facade aligned with the Python VeADK contract. */
+public final class LongTermMemory implements BaseMemoryService, AutoCloseable {
+
+ public static final String DEFAULT_INDEX = "default_app";
+ public static final int DEFAULT_TOP_K = 5;
+
+ private static final Logger log = LoggerFactory.getLogger(LongTermMemory.class);
+
+ private final LongTermMemoryBackend backend;
+ private final String appName;
+ private final int topK;
+
+ public LongTermMemory() {
+ this(DEFAULT_INDEX);
+ }
+
+ public LongTermMemory(String index) {
+ this(new InMemoryLongTermMemoryBackend(index), index, DEFAULT_TOP_K);
+ }
+
+ public LongTermMemory(LongTermMemoryBackend backend) {
+ this(backend, backend.index(), DEFAULT_TOP_K);
+ }
+
+ public LongTermMemory(LongTermMemoryBackend backend, int topK) {
+ this(backend, backend.index(), topK);
+ }
+
+ public LongTermMemory(LongTermMemoryBackend backend, String appName, int topK) {
+ this.backend = Objects.requireNonNull(backend, "backend");
+ this.appName = requireText(appName, "appName");
+ if (topK <= 0) {
+ throw new IllegalArgumentException("topK must be positive");
+ }
+ this.topK = topK;
+ }
+
+ public String index() {
+ return backend.index();
+ }
+
+ public String appName() {
+ return appName;
+ }
+
+ public String backend() {
+ return backend.backendName();
+ }
+
+ public int topK() {
+ return topK;
+ }
+
+ @Override
+ public Completable addSessionToMemory(Session session) {
+ Objects.requireNonNull(session, "session");
+ return Completable.fromAction(
+ () -> {
+ List eventStrings = serializeUserEvents(session.events());
+ if (eventStrings.isEmpty()) {
+ return;
+ }
+ Map options = new LinkedHashMap<>();
+ options.put(
+ "appName",
+ session.appName() == null || session.appName().isBlank()
+ ? appName
+ : session.appName());
+ options.put("sessionId", session.id());
+ backend.saveMemory(session.userId(), eventStrings, Map.copyOf(options));
+ });
+ }
+
+ @Override
+ public Single searchMemory(
+ String requestedAppName, String userId, String query) {
+ return Single.fromCallable(
+ () -> {
+ List chunks;
+ try {
+ chunks =
+ backend.searchMemory(
+ userId,
+ query,
+ topK,
+ Map.of(
+ "appName",
+ requestedAppName == null
+ || requestedAppName.isBlank()
+ ? appName
+ : requestedAppName));
+ } catch (Exception exception) {
+ log.warn(
+ "Long-term memory search failed; returning no memories", exception);
+ chunks = List.of();
+ }
+ List entries = new ArrayList<>();
+ chunks.forEach(chunk -> entries.addAll(toMemoryEntries(chunk)));
+ return SearchMemoryResponse.builder().setMemories(entries).build();
+ });
+ }
+
+ @Override
+ public void close() {
+ backend.close();
+ }
+
+ private static List serializeUserEvents(List events) {
+ List serialized = new ArrayList<>();
+ for (Event event : events) {
+ if (!"user".equals(event.author()) || event.content().isEmpty()) {
+ continue;
+ }
+ List parts = event.content().get().parts().orElse(List.of());
+ List