Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions argi-orchestration/argi-workflow-core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,29 @@ The existing Graph-backed Flow agents remain available in `argi-agent-framework`
Applications that need declarative independent execution depend on
`argi-workflow-core`, publish a `WorkflowDefinition`, and construct a
`WorkflowAgent` with their agent and tool resolvers.

## Definition data snapshots

Starting with the `2.1.0-RC2-SNAPSHOT` fix for #272, `WorkflowValue.Literal` and
definition metadata capture deeply immutable snapshots at construction, including
the `withMetadata` path. Caller mutations and collections returned by accessors
cannot alter a published version or its arguments during execution. Repeated
references within a snapshot reuse immutable copies; cyclic collections are rejected.

Supported data consists of null, strings, booleans, characters, the standard boxed
numeric types (`Byte`, `Short`, `Integer`, `Long`, `Float`, `Double`), exact
`BigInteger` and `BigDecimal` values, lists, and maps with string keys. Nested null
values are preserved. Top-level metadata keys and values still cannot be null.
Numbers retain their Java types and precision. Collection nesting is limited to
128 levels; unsupported objects, invalid map keys, cycles and excessive nesting
fail with `IllegalArgumentException` rather than being silently serialized or retained.

Applications passing custom objects, mutable `Number` implementations, arrays,
sets or non-string map keys must explicitly convert them to supported data before
constructing a definition. Code that edited values obtained from definition
accessors must instead create new data and publish a new workflow version. Public
constructor and accessor signatures, JSON DSL structure, and version identifiers
are unchanged. Runtime input and state mutability are outside this snapshot rule.
Existing definitions must be reconstructed from their original source to adopt
the fix; reverting the implementation restores the previous shallow-copy behavior
without migrating persisted data.
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
/*
* Copyright 2026 the original author or 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.
*/
package io.github.agentic.ai.workflow;

import java.math.BigDecimal;
import java.math.BigInteger;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;

/** Captures supported definition data without retaining caller-owned mutable references. */
final class WorkflowDataSnapshot {

private static final int MAX_CONTAINER_DEPTH = 128;

private final Map<Object, Snapshot> snapshots = new IdentityHashMap<>();

private final Map<Object, Boolean> visiting = new IdentityHashMap<>();

private WorkflowDataSnapshot() {
}

static Object value(Object value) {
return new WorkflowDataSnapshot().copy(value, 0);
}

@SuppressWarnings("unchecked")
static Map<String, Object> metadata(Map<String, Object> metadata) {
// Preserve the existing rejection of null top-level metadata keys and values.
for (Map.Entry<?, ?> entry : metadata.entrySet()) {
Objects.requireNonNull(entry.getKey(), "metadata key must not be null");
Objects.requireNonNull(entry.getValue(), "metadata value must not be null");
}
// copy() validates all keys and produces only Map<String, Object> map snapshots.
return (Map<String, Object>) value(metadata);
}

private Object copy(Object value, int depth) {
if (value == null || isScalar(value.getClass())) {
return value;
}
if (!(value instanceof Map<?, ?>) && !(value instanceof List<?>)) {
throw new IllegalArgumentException("Unsupported workflow definition value type: " + value.getClass().getName());
}
final Snapshot cached = snapshots.get(value);
if (depth >= MAX_CONTAINER_DEPTH || (cached != null && depth + cached.height() > MAX_CONTAINER_DEPTH)) {
throw new IllegalArgumentException("Workflow definition data exceeds " + MAX_CONTAINER_DEPTH
+ " nested collection levels");
}
if (visiting.containsKey(value)) {
throw new IllegalArgumentException("Workflow definition data must not contain cyclic collections");
}
if (cached != null) {
return cached.value();
}
visiting.put(value, true);
try {
final Object snapshot;
int height = 1;
if (value instanceof Map<?, ?> map) {
final Map<String, Object> result = new LinkedHashMap<>();
for (Map.Entry<?, ?> entry : map.entrySet()) {
if (!(entry.getKey() instanceof String key)) {
throw new IllegalArgumentException("Workflow definition data requires string map keys");
}
result.put(key, copy(entry.getValue(), depth + 1));
height = Math.max(height, 1 + heightOf(entry.getValue()));
}
snapshot = Collections.unmodifiableMap(result);
}
else {
final List<Object> result = new ArrayList<>();
for (Object element : (List<?>) value) {
result.add(copy(element, depth + 1));
height = Math.max(height, 1 + heightOf(element));
}
snapshot = Collections.unmodifiableList(result);
}
snapshots.put(value, new Snapshot(snapshot, height));
return snapshot;
}
finally {
visiting.remove(value);
}
}

private int heightOf(Object value) {
final Snapshot snapshot = snapshots.get(value);
return snapshot == null ? 0 : snapshot.height();
}

private record Snapshot(Object value, int height) {
}

private static boolean isScalar(Class<?> type) {
return type == String.class || type == Boolean.class || type == Character.class || type == Byte.class
|| type == Short.class || type == Integer.class || type == Long.class || type == Float.class
|| type == Double.class || type == BigInteger.class || type == BigDecimal.class;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ public record WorkflowDefinition(String id, String version, List<WorkflowStep> s
if (steps.isEmpty()) {
throw new IllegalArgumentException("workflow must contain at least one step");
}
metadata = metadata == null ? Map.of() : Map.copyOf(metadata);
metadata = metadata == null ? Map.of() : WorkflowDataSnapshot.metadata(metadata);
}

public WorkflowDefinition(String id, String version, List<WorkflowStep> steps) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,14 @@ public sealed interface WorkflowValue
permits WorkflowValue.Literal, WorkflowValue.Input, WorkflowValue.State, WorkflowValue.StepOutput,
WorkflowValue.Compare, WorkflowValue.All, WorkflowValue.Any, WorkflowValue.Not {

/**
* A detached immutable snapshot of supported definition data. Nested lists and
* string-keyed maps are copied; unsupported mutable objects are rejected.
*/
record Literal(Object value) implements WorkflowValue {
public Literal {
value = WorkflowDataSnapshot.value(value);
}
}

record Input(String path) implements WorkflowValue {
Expand Down
Loading
Loading