diff --git a/langgraph-java/.gitignore b/langgraph-java/.gitignore new file mode 100644 index 000000000..9de0e375c --- /dev/null +++ b/langgraph-java/.gitignore @@ -0,0 +1,90 @@ +############################## +## Java +############################## +.mtj.tmp/ +*.class +*.jar +*.war +*.ear +*.nar +hs_err_pid* +replay_pid* + +############################## +## Maven +############################## +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +pom.xml.bak +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +############################## +## Gradle +############################## +bin/ +build/ +.gradle +.gradletasknamecache +gradle-app.setting +!gradle-wrapper.jar + +############################## +## IntelliJ +############################## +out/ +.idea/ +.idea_modules/ +*.iml +*.ipr +*.iws + +############################## +## Eclipse +############################## +.settings/ +bin/ +tmp/ +.metadata +.classpath +.project +*.tmp +*.bak +*.swp +*~.nib +local.properties +.loadpath +.factorypath + +############################## +## NetBeans +############################## +nbproject/private/ +build/ +nbbuild/ +dist/ +nbdist/ +nbactions.xml +nb-configuration.xml + +############################## +## Visual Studio Code +############################## +.vscode/ +.code-workspace + +############################## +## OS X +############################## +.DS_Store + +############################## +## Miscellaneous +############################## +*.log diff --git a/langgraph-java/README.md b/langgraph-java/README.md new file mode 100644 index 000000000..7be507428 --- /dev/null +++ b/langgraph-java/README.md @@ -0,0 +1,63 @@ +# LangGraph Java + +A Java port of LangGraph, a framework for building stateful, observable applications with large language models (LLMs). + +## Project Structure + +- `langgraph-checkpoint`: Base persistence interfaces +- `langgraph-core`: Main library with channels, Pregel, and StateGraph +- `langgraph-examples`: Example applications + +## Requirements + +- Java 17 or higher +- Gradle 7.0 or higher + +## Building + +```bash +./gradlew build +``` + +## Features + +- Graph-based architecture with nodes and edges +- Type-safe state schema using Java Records +- Cyclical execution patterns +- Checkpoint integration for persistence +- Streaming support +- Human-in-the-loop capabilities + +## Usage Example + +```java +// Create a graph with our schema +StateGraph graph = new StateGraph<>(CounterState.class); + +// Add nodes +graph.addNode("increment", state -> { + Map updates = new HashMap<>(); + updates.put("count", state.count() + 1); + return updates; +}); + +// Add edges +graph.addEdge(START, "increment"); +graph.addEdge("increment", "check"); + +// Add conditional edge +graph.addConditionalEdges("check", state -> { + if (state.count() >= 3) { + return "finish"; + } + return "increment"; +}); + +// Compile and run +CompiledStateGraph compiled = graph.compile(); +CounterState result = compiled.invoke(new CounterState(0, "")); +``` + +## License + +This project is licensed under the MIT License - see the LICENSE file for details. \ No newline at end of file diff --git a/langgraph-java/build.gradle b/langgraph-java/build.gradle new file mode 100644 index 000000000..22afdbff6 --- /dev/null +++ b/langgraph-java/build.gradle @@ -0,0 +1,39 @@ +plugins { + id 'java-library' +} + +allprojects { + group = 'com.langgraph' + version = '0.1.0-SNAPSHOT' + + repositories { + mavenCentral() + } +} + +subprojects { + apply plugin: 'java-library' + + java { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + tasks.withType(JavaCompile) { + options.encoding = 'UTF-8' + options.compilerArgs << '-parameters' + } + + dependencies { + // Testing dependencies + testImplementation 'org.junit.jupiter:junit-jupiter-api:5.9.2' + testImplementation 'org.junit.jupiter:junit-jupiter-params:5.9.2' + testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine:5.9.2' + testImplementation 'org.mockito:mockito-core:5.2.0' + testImplementation 'org.assertj:assertj-core:3.24.2' + } + + test { + useJUnitPlatform() + } +} \ No newline at end of file diff --git a/langgraph-java/gradle/wrapper/gradle-wrapper.jar b/langgraph-java/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 000000000..9bbc975c7 Binary files /dev/null and b/langgraph-java/gradle/wrapper/gradle-wrapper.jar differ diff --git a/langgraph-java/gradle/wrapper/gradle-wrapper.properties b/langgraph-java/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..37f853b1c --- /dev/null +++ b/langgraph-java/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/langgraph-java/gradlew b/langgraph-java/gradlew new file mode 100755 index 000000000..faf93008b --- /dev/null +++ b/langgraph-java/gradlew @@ -0,0 +1,251 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# 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 +# +# https://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. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/langgraph-java/gradlew.bat b/langgraph-java/gradlew.bat new file mode 100644 index 000000000..9b42019c7 --- /dev/null +++ b/langgraph-java/gradlew.bat @@ -0,0 +1,94 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/langgraph-java/langgraph-checkpoint/build.gradle b/langgraph-java/langgraph-checkpoint/build.gradle new file mode 100644 index 000000000..32ed30801 --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/build.gradle @@ -0,0 +1,5 @@ +dependencies { + // MessagePack for serialization + implementation 'org.msgpack:msgpack-core:0.9.3' + implementation 'org.msgpack:jackson-dataformat-msgpack:0.9.3' +} \ No newline at end of file diff --git a/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/base/AsyncBaseCheckpointSaver.java b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/base/AsyncBaseCheckpointSaver.java new file mode 100644 index 000000000..995902f6d --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/base/AsyncBaseCheckpointSaver.java @@ -0,0 +1,60 @@ +package com.langgraph.checkpoint.base; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +/** + * Asynchronous interface for saving and loading checkpoints. + */ +public interface AsyncBaseCheckpointSaver { + /** + * Create a new checkpoint asynchronously. + * + * @param threadId The ID of the thread to checkpoint + * @param channelValues The values of the channels to checkpoint + * @return CompletableFuture with the ID of the new checkpoint + */ + CompletableFuture checkpointAsync(String threadId, Map channelValues); + + /** + * Get values from a checkpoint asynchronously. + * + * @param checkpointId The ID of the checkpoint to load + * @return CompletableFuture with the channel values from the checkpoint, or empty if not found + */ + CompletableFuture>> getValuesAsync(String checkpointId); + + /** + * List all checkpoints for a thread asynchronously. + * + * @param threadId The ID of the thread + * @return CompletableFuture with list of checkpoint IDs + */ + CompletableFuture> listAsync(String threadId); + + /** + * Get the latest checkpoint for a thread asynchronously. + * + * @param threadId The ID of the thread + * @return CompletableFuture with the ID of the latest checkpoint, or empty if none exists + */ + CompletableFuture> latestAsync(String threadId); + + /** + * Delete a checkpoint asynchronously. + * + * @param checkpointId The ID of the checkpoint to delete + * @return CompletableFuture completed when deletion is done + */ + CompletableFuture deleteAsync(String checkpointId); + + /** + * Clear all checkpoints for a thread asynchronously. + * + * @param threadId The ID of the thread + * @return CompletableFuture completed when clearing is done + */ + CompletableFuture clearAsync(String threadId); +} \ No newline at end of file diff --git a/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/base/BaseCheckpointSaver.java b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/base/BaseCheckpointSaver.java new file mode 100644 index 000000000..f8b7c90fd --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/base/BaseCheckpointSaver.java @@ -0,0 +1,57 @@ +package com.langgraph.checkpoint.base; + +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * Interface for saving and loading checkpoints. + */ +public interface BaseCheckpointSaver { + /** + * Create a new checkpoint. + * + * @param threadId The ID of the thread to checkpoint + * @param channelValues The values of the channels to checkpoint + * @return The ID of the new checkpoint + */ + String checkpoint(String threadId, Map channelValues); + + /** + * Get values from a checkpoint. + * + * @param checkpointId The ID of the checkpoint to load + * @return The channel values from the checkpoint, or empty if not found + */ + Optional> getValues(String checkpointId); + + /** + * List all checkpoints for a thread. + * + * @param threadId The ID of the thread + * @return List of checkpoint IDs + */ + List list(String threadId); + + /** + * Get the latest checkpoint for a thread. + * + * @param threadId The ID of the thread + * @return The ID of the latest checkpoint, or empty if none exists + */ + Optional latest(String threadId); + + /** + * Delete a checkpoint. + * + * @param checkpointId The ID of the checkpoint to delete + */ + void delete(String checkpointId); + + /** + * Clear all checkpoints for a thread. + * + * @param threadId The ID of the thread + */ + void clear(String threadId); +} \ No newline at end of file diff --git a/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/base/ID.java b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/base/ID.java new file mode 100644 index 000000000..2e2cf8c24 --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/base/ID.java @@ -0,0 +1,81 @@ +package com.langgraph.checkpoint.base; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Base64; +import java.util.UUID; + +/** + * Utility class for generating deterministic IDs. + */ +public final class ID { + private ID() { + // Prevent instantiation + } + + /** + * Generate a deterministic UUID based on a namespace and name. + * + * @param namespace The namespace for the ID + * @param name The name within the namespace + * @return A UUID + */ + public static UUID uuid(String namespace, String name) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-1"); + md.update(namespace.getBytes(StandardCharsets.UTF_8)); + md.update(name.getBytes(StandardCharsets.UTF_8)); + byte[] digest = md.digest(); + + // Set the version (4) and variant (RFC4122) bits + digest[6] = (byte) ((digest[6] & 0x0F) | 0x40); + digest[8] = (byte) ((digest[8] & 0x3F) | 0x80); + + long msb = 0; + long lsb = 0; + + for (int i = 0; i < 8; i++) { + msb = (msb << 8) | (digest[i] & 0xff); + } + + for (int i = 8; i < 16; i++) { + lsb = (lsb << 8) | (digest[i] & 0xff); + } + + return new UUID(msb, lsb); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-1 algorithm not available", e); + } + } + + /** + * Generate a checkpoint ID. + * + * @param threadId The thread ID + * @return A checkpoint ID + */ + public static String checkpointId(String threadId) { + return uuid("checkpoint", threadId + "/" + System.currentTimeMillis()).toString(); + } + + /** + * Generate a URL-safe base64 encoded ID. + * + * @param namespace The namespace for the ID + * @param name The name within the namespace + * @return A URL-safe base64-encoded ID + */ + public static String urlSafeId(String namespace, String name) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + md.update(namespace.getBytes(StandardCharsets.UTF_8)); + md.update(name.getBytes(StandardCharsets.UTF_8)); + byte[] digest = md.digest(); + + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 algorithm not available", e); + } + } +} \ No newline at end of file diff --git a/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/base/memory/AsyncMemoryCheckpointSaver.java b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/base/memory/AsyncMemoryCheckpointSaver.java new file mode 100644 index 000000000..e40a9216e --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/base/memory/AsyncMemoryCheckpointSaver.java @@ -0,0 +1,79 @@ +package com.langgraph.checkpoint.base.memory; + +import com.langgraph.checkpoint.base.AsyncBaseCheckpointSaver; +import com.langgraph.checkpoint.base.BaseCheckpointSaver; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; + +/** + * Asynchronous in-memory implementation of a checkpoint saver. + * This is a thin wrapper around the synchronous implementation that + * executes operations asynchronously. + */ +public class AsyncMemoryCheckpointSaver implements AsyncBaseCheckpointSaver { + private final BaseCheckpointSaver synchronousSaver; + + /** + * Create an async memory checkpoint saver. + */ + public AsyncMemoryCheckpointSaver() { + this.synchronousSaver = new MemoryCheckpointSaver(); + } + + /** + * Create an async memory checkpoint saver with an existing synchronous saver. + * + * @param synchronousSaver The synchronous checkpoint saver to wrap + */ + public AsyncMemoryCheckpointSaver(BaseCheckpointSaver synchronousSaver) { + this.synchronousSaver = synchronousSaver; + } + + @Override + public CompletableFuture checkpointAsync(String threadId, Map channelValues) { + return CompletableFuture.supplyAsync(() -> + synchronousSaver.checkpoint(threadId, channelValues)); + } + + @Override + public CompletableFuture>> getValuesAsync(String checkpointId) { + return CompletableFuture.supplyAsync(() -> + synchronousSaver.getValues(checkpointId)); + } + + @Override + public CompletableFuture> listAsync(String threadId) { + return CompletableFuture.supplyAsync(() -> + synchronousSaver.list(threadId)); + } + + @Override + public CompletableFuture> latestAsync(String threadId) { + return CompletableFuture.supplyAsync(() -> + synchronousSaver.latest(threadId)); + } + + @Override + public CompletableFuture deleteAsync(String checkpointId) { + return CompletableFuture.runAsync(() -> + synchronousSaver.delete(checkpointId)); + } + + @Override + public CompletableFuture clearAsync(String threadId) { + return CompletableFuture.runAsync(() -> + synchronousSaver.clear(threadId)); + } + + /** + * Get the underlying synchronous checkpoint saver. + * + * @return The synchronous checkpoint saver + */ + public BaseCheckpointSaver getSynchronousSaver() { + return synchronousSaver; + } +} \ No newline at end of file diff --git a/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/base/memory/MemoryCheckpointSaver.java b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/base/memory/MemoryCheckpointSaver.java new file mode 100644 index 000000000..b47fd4ea1 --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/base/memory/MemoryCheckpointSaver.java @@ -0,0 +1,77 @@ +package com.langgraph.checkpoint.base.memory; + +import com.langgraph.checkpoint.base.BaseCheckpointSaver; +import com.langgraph.checkpoint.base.ID; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory implementation of a checkpoint saver. + */ +public class MemoryCheckpointSaver implements BaseCheckpointSaver { + private final Map> checkpoints = new ConcurrentHashMap<>(); + private final Map> threadCheckpoints = new ConcurrentHashMap<>(); + + @Override + public String checkpoint(String threadId, Map channelValues) { + String checkpointId = ID.checkpointId(threadId); + + // Store the checkpoint + checkpoints.put(checkpointId, new HashMap<>(channelValues)); + + // Add to thread's checkpoints + threadCheckpoints.computeIfAbsent(threadId, k -> + Collections.synchronizedList(new ArrayList<>())).add(checkpointId); + + return checkpointId; + } + + @Override + public Optional> getValues(String checkpointId) { + Map values = checkpoints.get(checkpointId); + return Optional.ofNullable(values).map(HashMap::new); + } + + @Override + public List list(String threadId) { + List result = threadCheckpoints.get(threadId); + return result != null ? new ArrayList<>(result) : Collections.emptyList(); + } + + @Override + public Optional latest(String threadId) { + List checkpoints = threadCheckpoints.get(threadId); + + if (checkpoints == null || checkpoints.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(checkpoints.get(checkpoints.size() - 1)); + } + + @Override + public void delete(String checkpointId) { + // Remove the checkpoint + Map removed = checkpoints.remove(checkpointId); + + if (removed != null) { + // Find and remove from thread's checkpoints + for (List checkpointsList : threadCheckpoints.values()) { + checkpointsList.remove(checkpointId); + } + } + } + + @Override + public void clear(String threadId) { + List checkpointIds = threadCheckpoints.remove(threadId); + + if (checkpointIds != null) { + // Remove all checkpoints for this thread + for (String checkpointId : checkpointIds) { + checkpoints.remove(checkpointId); + } + } + } +} \ No newline at end of file diff --git a/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/MsgPackSerializer.java b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/MsgPackSerializer.java new file mode 100644 index 000000000..7881772b1 --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/MsgPackSerializer.java @@ -0,0 +1,548 @@ +package com.langgraph.checkpoint.serde; + +import org.msgpack.core.MessageBufferPacker; +import org.msgpack.core.MessagePack; +import org.msgpack.core.MessageUnpacker; +import org.msgpack.core.MessageFormat; + +import java.io.IOException; +import java.lang.reflect.*; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * MessagePack-based serializer that uses reflection to handle arbitrary Java objects. + * Supports primitive types, collections, maps, records, and custom objects. + */ +public class MsgPackSerializer implements ReflectionSerializer { + private final Map, TypeSerializer> serializers = new ConcurrentHashMap<>(); + private final Map, TypeDeserializer> deserializers = new ConcurrentHashMap<>(); + private final Map, RecordInfo> recordInfoCache = new ConcurrentHashMap<>(); + + /** + * Record component information cache to avoid repeated reflection. + */ + private static class RecordInfo { + final RecordComponent[] components; + final Constructor constructor; + + RecordInfo(RecordComponent[] components, Constructor constructor) { + this.components = components; + this.constructor = constructor; + } + } + + /** + * Register built-in serializers for common types. + */ + public MsgPackSerializer() { + registerBuiltinTypes(); + } + + /** + * Register built-in serializers for common types. + */ + private void registerBuiltinTypes() { + // UUID serializer + registerSerializer(UUID.class, (uuid) -> uuid.toString()); + registerDeserializer(UUID.class, (str) -> UUID.fromString((String) str)); + + // Date serializer + registerSerializer(java.util.Date.class, (date) -> date.getTime()); + registerDeserializer(java.util.Date.class, (millis) -> new Date((Long) millis)); + + // Java 8 Date/Time API + registerSerializer(Instant.class, (instant) -> instant.toString()); + registerDeserializer(Instant.class, (str) -> Instant.parse((String) str)); + + registerSerializer(LocalDate.class, (date) -> date.toString()); + registerDeserializer(LocalDate.class, (str) -> LocalDate.parse((String) str)); + + registerSerializer(LocalTime.class, (time) -> time.toString()); + registerDeserializer(LocalTime.class, (str) -> LocalTime.parse((String) str)); + + registerSerializer(LocalDateTime.class, (dateTime) -> dateTime.toString()); + registerDeserializer(LocalDateTime.class, (str) -> LocalDateTime.parse((String) str)); + + // Add more built-in serializers as needed + } + + @Override + public void registerSerializer(Class type, TypeSerializer serializer) { + serializers.put(type, serializer); + } + + @Override + public void registerDeserializer(Class type, TypeDeserializer deserializer) { + deserializers.put(type, deserializer); + } + + @Override + public byte[] serialize(Object obj) { + try { + MessageBufferPacker packer = MessagePack.newDefaultBufferPacker(); + serializeObject(obj, packer); + return packer.toByteArray(); + } catch (IOException e) { + throw new SerializationException("Failed to serialize object", e); + } + } + + @Override + public Object deserialize(byte[] data) { + try { + MessageUnpacker unpacker = MessagePack.newDefaultUnpacker(data); + return deserializeObject(unpacker); + } catch (IOException e) { + throw new SerializationException("Failed to deserialize object", e); + } + } + + /** + * Serialize an object to the MessagePack packer. + * + * @param obj Object to serialize + * @param packer MessagePack packer + * @throws IOException If packing fails + */ + @SuppressWarnings("unchecked") + private void serializeObject(Object obj, MessageBufferPacker packer) throws IOException { + if (obj == null) { + packer.packNil(); + return; + } + + Class type = obj.getClass(); + + // Check for registered serializer + if (serializers.containsKey(type)) { + TypeSerializer serializer = (TypeSerializer) serializers.get(type); + Object serialized = serializer.toSerializable(obj); + + // Pack as a special type + packer.packMapHeader(2); + packer.packString("__type__"); + packer.packString(type.getName()); + packer.packString("value"); + serializeObject(serialized, packer); + return; + } + + // Handle primitive types and common objects directly + if (obj instanceof String) { + packer.packString((String) obj); + } else if (obj instanceof Integer) { + packer.packInt((Integer) obj); + } else if (obj instanceof Long) { + packer.packLong((Long) obj); + } else if (obj instanceof Double) { + packer.packDouble((Double) obj); + } else if (obj instanceof Float) { + packer.packFloat((Float) obj); + } else if (obj instanceof Boolean) { + packer.packBoolean((Boolean) obj); + } else if (obj instanceof byte[]) { + packer.packBinaryHeader(((byte[]) obj).length); + packer.writePayload((byte[]) obj); + } else if (obj instanceof List) { + List list = (List) obj; + packer.packArrayHeader(list.size()); + for (Object item : list) { + serializeObject(item, packer); + } + } else if (obj instanceof Map) { + Map map = (Map) obj; + packer.packMapHeader(map.size()); + for (Map.Entry entry : map.entrySet()) { + serializeObject(entry.getKey(), packer); + serializeObject(entry.getValue(), packer); + } + } else if (obj instanceof Enum) { + // Handle enums by name + packer.packMapHeader(2); + packer.packString("__type__"); + packer.packString(type.getName()); + packer.packString("value"); + packer.packString(((Enum) obj).name()); + } else if (type.isRecord()) { + // Handle Record types + serializeRecord(obj, packer); + } else { + // Handle custom objects with reflection + serializeCustomObject(obj, packer); + } + } + + /** + * Serialize a Record object. + * + * @param record The record to serialize + * @param packer The MessagePack packer + * @throws IOException If packing fails + */ + private void serializeRecord(Object record, MessageBufferPacker packer) throws IOException { + Class recordClass = record.getClass(); + + // Pack as a special type with fields + packer.packMapHeader(2); + packer.packString("__type__"); + packer.packString(recordClass.getName()); + packer.packString("fields"); + + RecordComponent[] components = recordClass.getRecordComponents(); + packer.packMapHeader(components.length); + + for (RecordComponent component : components) { + packer.packString(component.getName()); + try { + Method accessor = component.getAccessor(); + Object value = accessor.invoke(record); + serializeObject(value, packer); + } catch (ReflectiveOperationException e) { + throw new SerializationException("Failed to access record component: " + component.getName(), e); + } + } + } + + /** + * Serialize a custom object using reflection. + * + * @param obj The object to serialize + * @param packer The MessagePack packer + * @throws IOException If packing fails + */ + private void serializeCustomObject(Object obj, MessageBufferPacker packer) throws IOException { + Class objClass = obj.getClass(); + + // Pack as a special type with fields + packer.packMapHeader(2); + packer.packString("__type__"); + packer.packString(objClass.getName()); + packer.packString("fields"); + + // Get all fields including inherited ones + List fields = getAllFields(objClass); + + // Filter out transient fields + List serializableFields = fields.stream() + .filter(field -> !Modifier.isTransient(field.getModifiers()) && + !Modifier.isStatic(field.getModifiers())) + .toList(); + + packer.packMapHeader(serializableFields.size()); + + for (Field field : serializableFields) { + packer.packString(field.getName()); + try { + field.setAccessible(true); + Object value = field.get(obj); + serializeObject(value, packer); + } catch (IllegalAccessException e) { + throw new SerializationException("Failed to access field: " + field.getName(), e); + } + } + } + + /** + * Get all fields for a class including inherited fields. + * + * @param clazz The class to get fields for + * @return List of all fields + */ + private List getAllFields(Class clazz) { + List fields = new ArrayList<>(); + Class currentClass = clazz; + + while (currentClass != null && currentClass != Object.class) { + fields.addAll(Arrays.asList(currentClass.getDeclaredFields())); + currentClass = currentClass.getSuperclass(); + } + + return fields; + } + + /** + * Deserialize an object from the MessagePack unpacker. + * + * @param unpacker MessagePack unpacker + * @return Deserialized object + * @throws IOException If unpacking fails + */ + @SuppressWarnings("unchecked") + private Object deserializeObject(MessageUnpacker unpacker) throws IOException { + if (!unpacker.hasNext()) { + throw new SerializationException("Unexpected end of data"); + } + + if (unpacker.tryUnpackNil()) { + return null; + } + + MessageFormat format = unpacker.getNextFormat(); + + if (format == MessageFormat.STR8 || + format == MessageFormat.STR16 || + format == MessageFormat.STR32 || + format == MessageFormat.FIXSTR) { + return unpacker.unpackString(); + } else if (format == MessageFormat.INT8 || + format == MessageFormat.INT16 || + format == MessageFormat.INT32 || + format == MessageFormat.INT64 || + format == MessageFormat.UINT8 || + format == MessageFormat.UINT16 || + format == MessageFormat.UINT32 || + format == MessageFormat.UINT64 || + format == MessageFormat.POSFIXINT || + format == MessageFormat.NEGFIXINT) { + if (format == MessageFormat.INT64 || format == MessageFormat.UINT64) { + return unpacker.unpackLong(); + } else { + try { + return unpacker.unpackInt(); + } catch (Exception e) { + // Fallback to long if int unpacking fails + return unpacker.unpackLong(); + } + } + } else if (format == MessageFormat.FLOAT32 || + format == MessageFormat.FLOAT64) { + return unpacker.unpackDouble(); + } else if (format == MessageFormat.BOOLEAN) { + return unpacker.unpackBoolean(); + } else if (format == MessageFormat.BIN8 || + format == MessageFormat.BIN16 || + format == MessageFormat.BIN32) { + int binaryLength = unpacker.unpackBinaryHeader(); + byte[] binary = new byte[binaryLength]; + unpacker.readPayload(binary); + return binary; + } else if (format == MessageFormat.ARRAY16 || + format == MessageFormat.ARRAY32 || + format == MessageFormat.FIXARRAY) { + int arraySize = unpacker.unpackArrayHeader(); + List list = new ArrayList<>(arraySize); + for (int i = 0; i < arraySize; i++) { + list.add(deserializeObject(unpacker)); + } + return list; + } else if (format == MessageFormat.MAP16 || + format == MessageFormat.MAP32 || + format == MessageFormat.FIXMAP) { + int mapSize = unpacker.unpackMapHeader(); + + // Handle empty map + if (mapSize == 0) { + return new HashMap<>(); + } + + // Check for special type marker + Object firstKey = deserializeObject(unpacker); + if (mapSize == 2 && firstKey instanceof String && "__type__".equals(firstKey)) { + String typeName = (String) deserializeObject(unpacker); + + // Get the second key + Object secondKey = deserializeObject(unpacker); + + if (secondKey instanceof String) { + String secondKeyStr = (String) secondKey; + + try { + Class type = Class.forName(typeName); + + // Check for registered deserializer + if ("value".equals(secondKeyStr) && deserializers.containsKey(type)) { + Object serialized = deserializeObject(unpacker); + TypeDeserializer deserializer = + (TypeDeserializer) deserializers.get(type); + return deserializer.fromSerialized(serialized); + } + + // Handle enums + if ("value".equals(secondKeyStr) && type.isEnum()) { + String enumValue = (String) deserializeObject(unpacker); + return Enum.valueOf((Class) type, enumValue); + } + + // Handle records + if ("fields".equals(secondKeyStr) && type.isRecord()) { + return deserializeRecord(type, unpacker); + } + + // Handle custom objects + if ("fields".equals(secondKeyStr)) { + return deserializeCustomObject(type, unpacker); + } + } catch (ClassNotFoundException e) { + // If class not found, fall back to regular map deserialization + } catch (ReflectiveOperationException e) { + throw new SerializationException("Failed to deserialize object of type " + typeName, e); + } + + // If special type handling failed, read the value to keep unpacker consistent + Object secondValue = deserializeObject(unpacker); + + // Create a fallback map with the special type info + Map fallbackMap = new HashMap<>(); + fallbackMap.put(firstKey, typeName); + fallbackMap.put(secondKey, secondValue); + return fallbackMap; + } + + // If the second key wasn't a string as expected, we need to handle it as a regular map + Object firstValue = deserializeObject(unpacker); + + // Create a map with the first key-value pair + Map map = new HashMap<>(mapSize); + map.put(firstKey, firstValue); + + // Read the remaining entries + for (int i = 1; i < mapSize; i++) { + Object key = deserializeObject(unpacker); + Object value = deserializeObject(unpacker); + map.put(key, value); + } + + return map; + } else { + // Regular map - we already read the first key + Map map = new HashMap<>(mapSize); + + // Read the first value + Object firstValue = deserializeObject(unpacker); + map.put(firstKey, firstValue); + + // Read the remaining entries + for (int i = 1; i < mapSize; i++) { + Object key = deserializeObject(unpacker); + Object value = deserializeObject(unpacker); + map.put(key, value); + } + + return map; + } + } + + // Default case + throw new SerializationException("Unsupported MessagePack format: " + format); + } + + /** + * Deserialize a record. + * + * @param recordClass The record class + * @param unpacker The unpacker containing the fields map + * @return The deserialized record + * @throws IOException If unpacking fails + * @throws ReflectiveOperationException If reflection operations fail + */ + private Object deserializeRecord(Class recordClass, MessageUnpacker unpacker) + throws IOException, ReflectiveOperationException { + + // Get record info from cache or create it + RecordInfo recordInfo = recordInfoCache.computeIfAbsent(recordClass, cls -> { + try { + RecordComponent[] components = cls.getRecordComponents(); + Class[] paramTypes = Arrays.stream(components) + .map(RecordComponent::getType) + .toArray(Class[]::new); + Constructor constructor = cls.getDeclaredConstructor(paramTypes); + constructor.setAccessible(true); + return new RecordInfo(components, constructor); + } catch (NoSuchMethodException e) { + throw new SerializationException("Failed to get constructor for record: " + cls.getName(), e); + } + }); + + // Read the fields map + int fieldCount = unpacker.unpackMapHeader(); + Map fieldValues = new HashMap<>(fieldCount); + + for (int i = 0; i < fieldCount; i++) { + String fieldName = (String) deserializeObject(unpacker); + Object fieldValue = deserializeObject(unpacker); + fieldValues.put(fieldName, fieldValue); + } + + // Prepare constructor arguments in the correct order + Object[] constructorArgs = new Object[recordInfo.components.length]; + for (int i = 0; i < recordInfo.components.length; i++) { + RecordComponent component = recordInfo.components[i]; + Object value = fieldValues.get(component.getName()); + constructorArgs[i] = value; + } + + // Create the record instance + return recordInfo.constructor.newInstance(constructorArgs); + } + + /** + * Deserialize a custom object. + * + * @param objectClass The object class + * @param unpacker The unpacker containing the fields map + * @return The deserialized object + * @throws IOException If unpacking fails + * @throws ReflectiveOperationException If reflection operations fail + */ + private Object deserializeCustomObject(Class objectClass, MessageUnpacker unpacker) + throws IOException, ReflectiveOperationException { + + // Create instance using default constructor + Constructor constructor; + try { + constructor = objectClass.getDeclaredConstructor(); + constructor.setAccessible(true); + } catch (NoSuchMethodException e) { + throw new SerializationException( + "Class " + objectClass.getName() + " must have a no-arg constructor for deserialization", e); + } + + Object instance = constructor.newInstance(); + + // Read the fields map + int fieldCount = unpacker.unpackMapHeader(); + + for (int i = 0; i < fieldCount; i++) { + String fieldName = (String) deserializeObject(unpacker); + Object fieldValue = deserializeObject(unpacker); + + try { + // Find the field (including in superclasses) + Field field = findField(objectClass, fieldName); + if (field != null) { + field.setAccessible(true); + field.set(instance, fieldValue); + } + } catch (NoSuchFieldException e) { + // Skip fields that don't exist in the current class version + } + } + + return instance; + } + + /** + * Find a field in a class or its superclasses. + * + * @param clazz The class to search + * @param fieldName The field name to find + * @return The found field + * @throws NoSuchFieldException If the field is not found + */ + private Field findField(Class clazz, String fieldName) throws NoSuchFieldException { + Class currentClass = clazz; + while (currentClass != null) { + try { + return currentClass.getDeclaredField(fieldName); + } catch (NoSuchFieldException e) { + currentClass = currentClass.getSuperclass(); + } + } + throw new NoSuchFieldException("Field not found: " + fieldName); + } +} \ No newline at end of file diff --git a/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/ReflectionSerializer.java b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/ReflectionSerializer.java new file mode 100644 index 000000000..d43b73423 --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/ReflectionSerializer.java @@ -0,0 +1,24 @@ +package com.langgraph.checkpoint.serde; + +/** + * Interface for a serializer that uses reflection to handle arbitrary Java objects. + */ +public interface ReflectionSerializer extends Serializer { + /** + * Register a custom serializer for a specific type. + * + * @param type Type to register + * @param serializer Custom serializer for the type + * @param Type to register + */ + void registerSerializer(Class type, TypeSerializer serializer); + + /** + * Register a custom deserializer for a specific type. + * + * @param type Type to register + * @param deserializer Custom deserializer for the type + * @param Type to register + */ + void registerDeserializer(Class type, TypeDeserializer deserializer); +} \ No newline at end of file diff --git a/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/SerializationException.java b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/SerializationException.java new file mode 100644 index 000000000..ccd828e7c --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/SerializationException.java @@ -0,0 +1,25 @@ +package com.langgraph.checkpoint.serde; + +/** + * Exception thrown during serialization/deserialization. + */ +public class SerializationException extends RuntimeException { + /** + * Create a new serialization exception with a message. + * + * @param message Error message + */ + public SerializationException(String message) { + super(message); + } + + /** + * Create a new serialization exception with a message and cause. + * + * @param message Error message + * @param cause Underlying cause + */ + public SerializationException(String message, Throwable cause) { + super(message, cause); + } +} \ No newline at end of file diff --git a/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/Serializer.java b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/Serializer.java new file mode 100644 index 000000000..ffcc48b38 --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/Serializer.java @@ -0,0 +1,24 @@ +package com.langgraph.checkpoint.serde; + +/** + * Interface for serializing and deserializing objects. + * + * @param Type of object to serialize/deserialize + */ +public interface Serializer { + /** + * Serialize an object to bytes. + * + * @param obj The object to serialize + * @return Serialized bytes + */ + byte[] serialize(T obj); + + /** + * Deserialize bytes to an object. + * + * @param data The bytes to deserialize + * @return Deserialized object + */ + T deserialize(byte[] data); +} \ No newline at end of file diff --git a/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/TypeDeserializer.java b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/TypeDeserializer.java new file mode 100644 index 000000000..786446b73 --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/TypeDeserializer.java @@ -0,0 +1,17 @@ +package com.langgraph.checkpoint.serde; + +/** + * Interface for deserializing a specific type from MessagePack. + * + * @param Type to deserialize + */ +@FunctionalInterface +public interface TypeDeserializer { + /** + * Convert from serialized representation to object. + * + * @param serialized Serialized representation + * @return Deserialized object + */ + T fromSerialized(Object serialized); +} \ No newline at end of file diff --git a/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/TypeSerializer.java b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/TypeSerializer.java new file mode 100644 index 000000000..3f54e3985 --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/src/main/java/com/langgraph/checkpoint/serde/TypeSerializer.java @@ -0,0 +1,17 @@ +package com.langgraph.checkpoint.serde; + +/** + * Interface for serializing a specific type to a format that can be included in MessagePack. + * + * @param Type to serialize + */ +@FunctionalInterface +public interface TypeSerializer { + /** + * Convert object to a serializable representation. + * + * @param obj Object to convert + * @return Serializable representation (must be compatible with MessagePack) + */ + Object toSerializable(T obj); +} \ No newline at end of file diff --git a/langgraph-java/langgraph-checkpoint/src/test/java/com/langgraph/checkpoint/base/IDTest.java b/langgraph-java/langgraph-checkpoint/src/test/java/com/langgraph/checkpoint/base/IDTest.java new file mode 100644 index 000000000..14ae8f84e --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/src/test/java/com/langgraph/checkpoint/base/IDTest.java @@ -0,0 +1,59 @@ +package com.langgraph.checkpoint.base; + +import org.junit.jupiter.api.Test; + +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +public class IDTest { + + @Test + public void testUuidDeterministic() { + // Same inputs should produce same UUIDs + UUID uuid1 = ID.uuid("test", "value"); + UUID uuid2 = ID.uuid("test", "value"); + + assertThat(uuid1).isEqualTo(uuid2); + } + + @Test + public void testUuidDifferentNamespace() { + // Different namespaces should produce different UUIDs + UUID uuid1 = ID.uuid("namespace1", "value"); + UUID uuid2 = ID.uuid("namespace2", "value"); + + assertThat(uuid1).isNotEqualTo(uuid2); + } + + @Test + public void testUuidDifferentName() { + // Different names should produce different UUIDs + UUID uuid1 = ID.uuid("test", "value1"); + UUID uuid2 = ID.uuid("test", "value2"); + + assertThat(uuid1).isNotEqualTo(uuid2); + } + + @Test + public void testCheckpointId() { + // Checkpoint IDs should be valid UUIDs + String id = ID.checkpointId("thread-123"); + + // Should be a valid UUID string + UUID uuid = UUID.fromString(id); + assertThat(uuid).isNotNull(); + } + + @Test + public void testUrlSafeId() { + // URL-safe IDs should be deterministic + String id1 = ID.urlSafeId("test", "value"); + String id2 = ID.urlSafeId("test", "value"); + + assertThat(id1).isEqualTo(id2); + + // Should not contain padding characters or unsafe URL characters + assertThat(id1).doesNotContain("=", "+", "/"); + } +} \ No newline at end of file diff --git a/langgraph-java/langgraph-checkpoint/src/test/java/com/langgraph/checkpoint/base/memory/AsyncMemoryCheckpointSaverTest.java b/langgraph-java/langgraph-checkpoint/src/test/java/com/langgraph/checkpoint/base/memory/AsyncMemoryCheckpointSaverTest.java new file mode 100644 index 000000000..1a8af1f35 --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/src/test/java/com/langgraph/checkpoint/base/memory/AsyncMemoryCheckpointSaverTest.java @@ -0,0 +1,155 @@ +package com.langgraph.checkpoint.base.memory; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutionException; + +import static org.assertj.core.api.Assertions.assertThat; + +public class AsyncMemoryCheckpointSaverTest { + + private AsyncMemoryCheckpointSaver saver; + + @BeforeEach + public void setUp() { + saver = new AsyncMemoryCheckpointSaver(); + } + + @Test + public void testCheckpointAsync() throws ExecutionException, InterruptedException { + // Create test data + String threadId = "test-thread"; + Map values = new HashMap<>(); + values.put("key1", "value1"); + values.put("key2", 42); + + // Create checkpoint asynchronously + CompletableFuture future = saver.checkpointAsync(threadId, values); + + // Wait for completion + String checkpointId = future.get(); + + // Verify checkpoint ID format (should be a UUID) + assertThat(checkpointId).matches("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"); + + // Verify thread has a checkpoint + CompletableFuture> listFuture = saver.listAsync(threadId); + List checkpoints = listFuture.get(); + assertThat(checkpoints).hasSize(1); + assertThat(checkpoints).contains(checkpointId); + } + + @Test + public void testGetValuesAsync() throws ExecutionException, InterruptedException { + // Create test data + String threadId = "test-thread"; + Map values = new HashMap<>(); + values.put("key1", "value1"); + values.put("key2", 42); + + // Create checkpoint + String checkpointId = saver.checkpointAsync(threadId, values).get(); + + // Get values asynchronously + CompletableFuture>> future = saver.getValuesAsync(checkpointId); + Optional> retrievedValues = future.get(); + + // Verify values + assertThat(retrievedValues).isPresent(); + assertThat(retrievedValues.get()).containsEntry("key1", "value1"); + assertThat(retrievedValues.get()).containsEntry("key2", 42); + } + + @Test + public void testListAsync() throws ExecutionException, InterruptedException { + // Create test data + String threadId = "test-thread"; + + // Initially empty + CompletableFuture> initialFuture = saver.listAsync(threadId); + List initial = initialFuture.get(); + assertThat(initial).isEmpty(); + + // Create multiple checkpoints + String id1 = saver.checkpointAsync(threadId, Map.of("key", "value1")).get(); + String id2 = saver.checkpointAsync(threadId, Map.of("key", "value2")).get(); + String id3 = saver.checkpointAsync(threadId, Map.of("key", "value3")).get(); + + // List checkpoints asynchronously + CompletableFuture> future = saver.listAsync(threadId); + List checkpoints = future.get(); + + // Verify order and content + assertThat(checkpoints).hasSize(3); + assertThat(checkpoints).containsExactly(id1, id2, id3); + } + + @Test + public void testLatestAsync() throws ExecutionException, InterruptedException { + // Create test data + String threadId = "test-thread"; + + // Initially empty + CompletableFuture> initialFuture = saver.latestAsync(threadId); + Optional initial = initialFuture.get(); + assertThat(initial).isEmpty(); + + // Create multiple checkpoints + saver.checkpointAsync(threadId, Map.of("key", "value1")).get(); + saver.checkpointAsync(threadId, Map.of("key", "value2")).get(); + String id3 = saver.checkpointAsync(threadId, Map.of("key", "value3")).get(); + + // Get latest asynchronously + CompletableFuture> future = saver.latestAsync(threadId); + Optional latest = future.get(); + + // Verify latest + assertThat(latest).isPresent(); + assertThat(latest.get()).isEqualTo(id3); + } + + @Test + public void testDeleteAsync() throws ExecutionException, InterruptedException { + // Create test data + String threadId = "test-thread"; + + // Create checkpoint + String checkpointId = saver.checkpointAsync(threadId, Map.of("key", "value")).get(); + + // Verify checkpoint exists + assertThat(saver.getValuesAsync(checkpointId).get()).isPresent(); + + // Delete checkpoint asynchronously + CompletableFuture future = saver.deleteAsync(checkpointId); + future.get(); // Wait for completion + + // Verify checkpoint is deleted + assertThat(saver.getValuesAsync(checkpointId).get()).isEmpty(); + } + + @Test + public void testClearAsync() throws ExecutionException, InterruptedException { + // Create test data + String threadId = "test-thread"; + + // Create multiple checkpoints + String id1 = saver.checkpointAsync(threadId, Map.of("key", "value1")).get(); + String id2 = saver.checkpointAsync(threadId, Map.of("key", "value2")).get(); + + // Verify checkpoints exist + assertThat(saver.listAsync(threadId).get()).hasSize(2); + + // Clear thread asynchronously + CompletableFuture future = saver.clearAsync(threadId); + future.get(); // Wait for completion + + // Verify checkpoints are deleted + assertThat(saver.listAsync(threadId).get()).isEmpty(); + } +} \ No newline at end of file diff --git a/langgraph-java/langgraph-checkpoint/src/test/java/com/langgraph/checkpoint/base/memory/MemoryCheckpointSaverTest.java b/langgraph-java/langgraph-checkpoint/src/test/java/com/langgraph/checkpoint/base/memory/MemoryCheckpointSaverTest.java new file mode 100644 index 000000000..805e8f194 --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/src/test/java/com/langgraph/checkpoint/base/memory/MemoryCheckpointSaverTest.java @@ -0,0 +1,161 @@ +package com.langgraph.checkpoint.base.memory; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +public class MemoryCheckpointSaverTest { + + private MemoryCheckpointSaver saver; + + @BeforeEach + public void setUp() { + saver = new MemoryCheckpointSaver(); + } + + @Test + public void testCheckpoint() { + // Create test data + String threadId = "test-thread"; + Map values = new HashMap<>(); + values.put("key1", "value1"); + values.put("key2", 42); + + // Create checkpoint + String checkpointId = saver.checkpoint(threadId, values); + + // Verify checkpoint ID format (should be a UUID) + assertThat(checkpointId).matches("^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"); + + // Verify thread has a checkpoint + List checkpoints = saver.list(threadId); + assertThat(checkpoints).hasSize(1); + assertThat(checkpoints).contains(checkpointId); + + // Verify latest checkpoint + Optional latest = saver.latest(threadId); + assertThat(latest).isPresent(); + assertThat(latest.get()).isEqualTo(checkpointId); + } + + @Test + public void testGetValues() { + // Create test data + String threadId = "test-thread"; + Map values = new HashMap<>(); + values.put("key1", "value1"); + values.put("key2", 42); + + // Create checkpoint + String checkpointId = saver.checkpoint(threadId, values); + + // Get values + Optional> retrievedValues = saver.getValues(checkpointId); + + // Verify values + assertThat(retrievedValues).isPresent(); + assertThat(retrievedValues.get()).containsEntry("key1", "value1"); + assertThat(retrievedValues.get()).containsEntry("key2", 42); + + // Verify non-existent checkpoint + Optional> nonExistent = saver.getValues("non-existent"); + assertThat(nonExistent).isEmpty(); + } + + @Test + public void testList() { + // Create test data + String threadId = "test-thread"; + + // Initially empty + List initial = saver.list(threadId); + assertThat(initial).isEmpty(); + + // Create multiple checkpoints + String id1 = saver.checkpoint(threadId, Map.of("key", "value1")); + String id2 = saver.checkpoint(threadId, Map.of("key", "value2")); + String id3 = saver.checkpoint(threadId, Map.of("key", "value3")); + + // List checkpoints + List checkpoints = saver.list(threadId); + + // Verify order and content + assertThat(checkpoints).hasSize(3); + assertThat(checkpoints).containsExactly(id1, id2, id3); + + // Different thread should have no checkpoints + List otherThread = saver.list("other-thread"); + assertThat(otherThread).isEmpty(); + } + + @Test + public void testLatest() { + // Create test data + String threadId = "test-thread"; + + // Initially empty + Optional initial = saver.latest(threadId); + assertThat(initial).isEmpty(); + + // Create multiple checkpoints + saver.checkpoint(threadId, Map.of("key", "value1")); + saver.checkpoint(threadId, Map.of("key", "value2")); + String id3 = saver.checkpoint(threadId, Map.of("key", "value3")); + + // Get latest + Optional latest = saver.latest(threadId); + + // Verify latest + assertThat(latest).isPresent(); + assertThat(latest.get()).isEqualTo(id3); + } + + @Test + public void testDelete() { + // Create test data + String threadId = "test-thread"; + + // Create checkpoint + String checkpointId = saver.checkpoint(threadId, Map.of("key", "value")); + + // Verify checkpoint exists + assertThat(saver.getValues(checkpointId)).isPresent(); + assertThat(saver.list(threadId)).contains(checkpointId); + + // Delete checkpoint + saver.delete(checkpointId); + + // Verify checkpoint is deleted + assertThat(saver.getValues(checkpointId)).isEmpty(); + assertThat(saver.list(threadId)).doesNotContain(checkpointId); + } + + @Test + public void testClear() { + // Create test data + String threadId = "test-thread"; + + // Create multiple checkpoints + String id1 = saver.checkpoint(threadId, Map.of("key", "value1")); + String id2 = saver.checkpoint(threadId, Map.of("key", "value2")); + + // Verify checkpoints exist + assertThat(saver.list(threadId)).hasSize(2); + assertThat(saver.getValues(id1)).isPresent(); + assertThat(saver.getValues(id2)).isPresent(); + + // Clear thread + saver.clear(threadId); + + // Verify checkpoints are deleted + assertThat(saver.list(threadId)).isEmpty(); + assertThat(saver.getValues(id1)).isEmpty(); + assertThat(saver.getValues(id2)).isEmpty(); + } +} \ No newline at end of file diff --git a/langgraph-java/langgraph-checkpoint/src/test/java/com/langgraph/checkpoint/serde/MsgPackSerializerTest.java b/langgraph-java/langgraph-checkpoint/src/test/java/com/langgraph/checkpoint/serde/MsgPackSerializerTest.java new file mode 100644 index 000000000..733f5769d --- /dev/null +++ b/langgraph-java/langgraph-checkpoint/src/test/java/com/langgraph/checkpoint/serde/MsgPackSerializerTest.java @@ -0,0 +1,383 @@ +package com.langgraph.checkpoint.serde; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.util.*; +import java.util.Objects; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +public class MsgPackSerializerTest { + + private MsgPackSerializer serializer; + + @BeforeEach + public void setUp() { + serializer = new MsgPackSerializer(); + } + + @Test + public void testSerializeDeserializePrimitives() { + // Test with various primitive types + assertRoundTrip("Test string"); + assertRoundTrip(123); + assertRoundTrip(123456789L); + assertRoundTrip(123.45); + assertRoundTrip(123.45f); + assertRoundTrip(true); + assertRoundTrip(false); + assertRoundTrip(null); + } + + @Test + public void testSerializeDeserializeArrays() { + // Test with arrays and collections + assertRoundTrip(new byte[] {1, 2, 3, 4, 5}); + assertRoundTrip(Arrays.asList("one", "two", "three")); + assertRoundTrip(Arrays.asList(1, 2, 3, 4, 5)); + } + + @Test + public void testSerializeDeserializeMaps() { + // Test with maps + Map map = new HashMap<>(); + map.put("string", "value"); + map.put("int", 123); + map.put("boolean", true); + + assertRoundTrip(map); + } + + @Test + public void testSerializeDeserializeNestedStructures() { + // Test with nested structures + Map nested = new HashMap<>(); + nested.put("list", Arrays.asList(1, 2, 3)); + nested.put("map", Map.of("key", "value")); + + assertRoundTrip(nested); + } + + @Test + public void testSerializeDeserializeEnums() { + // Test with enums + assertRoundTrip(TestEnum.VALUE1); + assertRoundTrip(TestEnum.VALUE2); + assertRoundTrip(TestEnum.VALUE3); + } + + @Test + public void testSerializeDeserializeRecord() { + // Test with a record + TestRecord record = new TestRecord("test", 123, Arrays.asList("a", "b", "c")); + + // Serialize and deserialize + byte[] serialized = serializer.serialize(record); + Object deserialized = serializer.deserialize(serialized); + + // Verify + assertThat(deserialized).isInstanceOf(TestRecord.class); + TestRecord deserializedRecord = (TestRecord) deserialized; + assertThat(deserializedRecord.name()).isEqualTo("test"); + assertThat(deserializedRecord.value()).isEqualTo(123); + assertThat(deserializedRecord.tags()).containsExactly("a", "b", "c"); + } + + @Test + public void testSerializeDeserializeNestedRecord() { + // Test with a nested record + NestedTestRecord record = new NestedTestRecord( + "parent", + new TestRecord("child", 456, Arrays.asList("x", "y", "z")) + ); + + // Serialize and deserialize + byte[] serialized = serializer.serialize(record); + Object deserialized = serializer.deserialize(serialized); + + // Verify + assertThat(deserialized).isInstanceOf(NestedTestRecord.class); + NestedTestRecord deserializedRecord = (NestedTestRecord) deserialized; + assertThat(deserializedRecord.name()).isEqualTo("parent"); + assertThat(deserializedRecord.child()).isInstanceOf(TestRecord.class); + assertThat(deserializedRecord.child().name()).isEqualTo("child"); + assertThat(deserializedRecord.child().value()).isEqualTo(456); + assertThat(deserializedRecord.child().tags()).containsExactly("x", "y", "z"); + } + + @Test + public void testSerializeDeserializeCustomObject() { + // Test with a custom object + TestObject obj = new TestObject(); + obj.setName("test"); + obj.setValue(123); + obj.setActive(true); + + // Serialize and deserialize + byte[] serialized = serializer.serialize(obj); + Object deserialized = serializer.deserialize(serialized); + + // Verify + assertThat(deserialized).isInstanceOf(TestObject.class); + TestObject deserializedObj = (TestObject) deserialized; + assertThat(deserializedObj.getName()).isEqualTo("test"); + assertThat(deserializedObj.getValue()).isEqualTo(123); + assertThat(deserializedObj.isActive()).isTrue(); + } + + @Test + public void testSerializeDeserializeInheritance() { + // Test with inheritance + ChildTestObject obj = new ChildTestObject(); + obj.setName("parent"); + obj.setValue(123); + obj.setActive(true); + obj.setChildProperty("child"); + obj.setChildValue(456); + + // Serialize and deserialize + byte[] serialized = serializer.serialize(obj); + Object deserialized = serializer.deserialize(serialized); + + // Verify + assertThat(deserialized).isInstanceOf(ChildTestObject.class); + ChildTestObject deserializedObj = (ChildTestObject) deserialized; + assertThat(deserializedObj.getName()).isEqualTo("parent"); + assertThat(deserializedObj.getValue()).isEqualTo(123); + assertThat(deserializedObj.isActive()).isTrue(); + assertThat(deserializedObj.getChildProperty()).isEqualTo("child"); + assertThat(deserializedObj.getChildValue()).isEqualTo(456); + } + + @Test + public void testSerializeDeserializeWithCustomSerializer() { + // Register custom UUID serializer (although built-in one exists) + serializer.registerSerializer(UUID.class, (uuid) -> uuid.toString().replace("-", "")); + serializer.registerDeserializer(UUID.class, (str) -> { + String uuidStr = (String) str; + // Insert hyphens for standard UUID format + uuidStr = uuidStr.replaceFirst( + "(\\p{XDigit}{8})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}{4})(\\p{XDigit}+)", + "$1-$2-$3-$4-$5"); + return UUID.fromString(uuidStr); + }); + + // Test with UUID + UUID uuid = UUID.randomUUID(); + + // Serialize and deserialize + byte[] serialized = serializer.serialize(uuid); + Object deserialized = serializer.deserialize(serialized); + + // Verify + assertThat(deserialized).isInstanceOf(UUID.class); + assertThat(deserialized).isEqualTo(uuid); + } + + @Test + public void testSerializeDeserializeDateTypes() { + // Test with Date + Date date = new Date(); + + // Serialize and deserialize + byte[] serialized = serializer.serialize(date); + Object deserialized = serializer.deserialize(serialized); + + // Verify + assertThat(deserialized).isInstanceOf(Date.class); + assertThat(deserialized).isEqualTo(date); + + // Test with Java 8 Date/Time types + Instant instant = Instant.now(); + LocalDate localDate = LocalDate.now(); + LocalTime localTime = LocalTime.now(); + LocalDateTime localDateTime = LocalDateTime.now(); + + assertRoundTrip(instant); + assertRoundTrip(localDate); + assertRoundTrip(localTime); + assertRoundTrip(localDateTime); + } + + @Test + public void testTransientFields() { + // Test with transient fields + ObjectWithTransient obj = new ObjectWithTransient(); + obj.setPersistent("saved"); + obj.setTransientField("not-saved"); + + // Serialize and deserialize + byte[] serialized = serializer.serialize(obj); + Object deserialized = serializer.deserialize(serialized); + + // Verify + assertThat(deserialized).isInstanceOf(ObjectWithTransient.class); + ObjectWithTransient deserializedObj = (ObjectWithTransient) deserialized; + assertThat(deserializedObj.getPersistent()).isEqualTo("saved"); + assertThat(deserializedObj.getTransientField()).isNull(); // Should be null after deserialization + } + + /** + * Helper method to assert that an object survives a round trip through serialization. + * + * @param obj Object to test + */ + private void assertRoundTrip(Object obj) { + try { + // Serialize + byte[] serialized = serializer.serialize(obj); + + // Deserialize + Object deserialized = serializer.deserialize(serialized); + + // Verify + if (obj instanceof byte[]) { + // Arrays need special comparison + assertThat(deserialized).isInstanceOf(byte[].class); + assertThat((byte[]) deserialized).isEqualTo((byte[]) obj); + } else if (obj instanceof Number) { + // For any number type, compare by value instead of exact type + if (deserialized instanceof Number) { + double expected = ((Number) obj).doubleValue(); + double actual = ((Number) deserialized).doubleValue(); + assertThat(actual).isCloseTo(expected, within(0.0001)); + } else { + throw new AssertionError("Expected Number, got " + + (deserialized != null ? deserialized.getClass().getName() : "null")); + } + } else { + // Special handling for lists + if (obj instanceof List && deserialized instanceof List) { + List originalList = (List) obj; + List deserializedList = (List) deserialized; + assertThat(deserializedList).hasSameSizeAs(originalList); + + // Check each element + for (int i = 0; i < originalList.size(); i++) { + Object originalItem = originalList.get(i); + Object deserializedItem = deserializedList.get(i); + + if (originalItem instanceof Number && deserializedItem instanceof Number) { + // Compare numbers by value instead of exact type + assertThat(((Number) deserializedItem).doubleValue()) + .isCloseTo(((Number) originalItem).doubleValue(), within(0.0001)); + } else { + assertThat(deserializedItem).isEqualTo(originalItem); + } + } + } else { + // Regular equality for other types + assertThat(deserialized).isEqualTo(obj); + } + } + } catch (Exception e) { + throw new AssertionError("Error in roundtrip for " + obj + ": " + e.getMessage(), e); + } + } + + /** + * Test enum. + */ + public enum TestEnum { + VALUE1, VALUE2, VALUE3 + } + + /** + * Test record class. + */ + public record TestRecord(String name, int value, List tags) { + } + + /** + * Nested test record class. + */ + public record NestedTestRecord(String name, TestRecord child) { + } + + /** + * Test class for custom object serialization. + */ + public static class TestObject { + private String name; + private int value; + private boolean active; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getValue() { + return value; + } + + public void setValue(int value) { + this.value = value; + } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + } + + /** + * Child test class for inheritance testing. + */ + public static class ChildTestObject extends TestObject { + private String childProperty; + private int childValue; + + public String getChildProperty() { + return childProperty; + } + + public void setChildProperty(String childProperty) { + this.childProperty = childProperty; + } + + public int getChildValue() { + return childValue; + } + + public void setChildValue(int childValue) { + this.childValue = childValue; + } + } + + /** + * Test class with transient fields. + */ + public static class ObjectWithTransient { + private String persistent; + private transient String transientField; + + public String getPersistent() { + return persistent; + } + + public void setPersistent(String persistent) { + this.persistent = persistent; + } + + public String getTransientField() { + return transientField; + } + + public void setTransientField(String transientField) { + this.transientField = transientField; + } + } +} \ No newline at end of file diff --git a/langgraph-java/settings.gradle b/langgraph-java/settings.gradle new file mode 100644 index 000000000..259b908f1 --- /dev/null +++ b/langgraph-java/settings.gradle @@ -0,0 +1,5 @@ +rootProject.name = 'langgraph-java' + +include 'langgraph-checkpoint' +include 'langgraph-core' +include 'langgraph-examples' \ No newline at end of file