Skip to content

Commit 83deef1

Browse files
committed
Add loop DSL
Signed-off-by: Ricardo Zanini <[email protected]>
1 parent 2015576 commit 83deef1

File tree

9 files changed

+285
-13
lines changed

9 files changed

+285
-13
lines changed

fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/AgentAdapters.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,10 @@
2020
import dev.langchain4j.agentic.Cognisphere;
2121
import dev.langchain4j.agentic.internal.AgentExecutor;
2222
import dev.langchain4j.agentic.internal.AgentInstance;
23+
import io.serverlessworkflow.impl.expressions.LoopPredicateIndex;
2324
import java.util.List;
2425
import java.util.function.Function;
26+
import java.util.function.Predicate;
2527
import java.util.stream.Stream;
2628

2729
public final class AgentAdapters {
@@ -34,4 +36,8 @@ public static List<AgentExecutor> toExecutors(Object... agents) {
3436
public static Function<Cognisphere, Object> toFunction(AgentExecutor exec) {
3537
return exec::invoke;
3638
}
39+
40+
public static LoopPredicateIndex<Object, Object> toWhile(Predicate<Cognisphere> exit) {
41+
return (model, item, idx) -> !exit.test((Cognisphere) model);
42+
}
3743
}

fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/AgentDoTaskFluent.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package io.serverlessworkflow.fluent.agentic;
1717

1818
import java.util.UUID;
19+
import java.util.function.Consumer;
1920

2021
public interface AgentDoTaskFluent<SELF extends AgentDoTaskFluent<SELF>> {
2122

@@ -30,4 +31,10 @@ default SELF agent(Object agent) {
3031
default SELF sequence(Object... agents) {
3132
return sequence("seq-" + UUID.randomUUID(), agents);
3233
}
34+
35+
SELF loop(String name, Consumer<LoopAgentsBuilder> builder);
36+
37+
default SELF loop(Consumer<LoopAgentsBuilder> builder) {
38+
return loop("loop-" + UUID.randomUUID(), builder);
39+
}
3340
}

fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/AgentTaskItemListBuilder.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@
1515
*/
1616
package io.serverlessworkflow.fluent.agentic;
1717

18+
import io.serverlessworkflow.api.types.Task;
19+
import io.serverlessworkflow.api.types.TaskItem;
1820
import io.serverlessworkflow.fluent.func.FuncTaskItemListBuilder;
1921
import io.serverlessworkflow.fluent.spec.BaseTaskItemListBuilder;
22+
import java.util.function.Consumer;
2023

2124
public class AgentTaskItemListBuilder extends BaseTaskItemListBuilder<AgentTaskItemListBuilder>
2225
implements AgentDoTaskFluent<AgentTaskItemListBuilder> {
@@ -50,6 +53,14 @@ public AgentTaskItemListBuilder sequence(String name, Object... agents) {
5053
return self();
5154
}
5255

56+
@Override
57+
public AgentTaskItemListBuilder loop(String name, Consumer<LoopAgentsBuilder> consumer) {
58+
final LoopAgentsBuilder builder = new LoopAgentsBuilder();
59+
consumer.accept(builder);
60+
this.addTaskItem(new TaskItem(name, new Task().withForTask(builder.build())));
61+
return self();
62+
}
63+
5364
@Override
5465
public AgentTaskItemListBuilder self() {
5566
return this;

fluent/agentic/src/main/java/io/serverlessworkflow/fluent/agentic/DelegatingAgentDoTaskFluent.java

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717

1818
import io.serverlessworkflow.fluent.func.DelegatingFuncDoTaskFluent;
1919
import io.serverlessworkflow.fluent.spec.HasDelegate;
20+
import java.util.function.Consumer;
2021

2122
public interface DelegatingAgentDoTaskFluent<SELF extends DelegatingAgentDoTaskFluent<SELF>>
2223
extends AgentDoTaskFluent<SELF>, DelegatingFuncDoTaskFluent<SELF>, HasDelegate {
@@ -39,4 +40,10 @@ default SELF sequence(String name, Object... agents) {
3940
d().sequence(name, agents);
4041
return (SELF) this;
4142
}
43+
44+
@SuppressWarnings("unchecked")
45+
default SELF loop(String name, Consumer<LoopAgentsBuilder> consumer) {
46+
d().loop(name, consumer);
47+
return (SELF) this;
48+
}
4249
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
/*
2+
* Copyright 2020-Present The Serverless Workflow Specification Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.serverlessworkflow.fluent.agentic;
17+
18+
import dev.langchain4j.agentic.Cognisphere;
19+
import dev.langchain4j.agentic.internal.AgentExecutor;
20+
import io.serverlessworkflow.api.types.ForTaskConfiguration;
21+
import io.serverlessworkflow.api.types.func.ForTaskFunction;
22+
import io.serverlessworkflow.fluent.func.FuncTaskItemListBuilder;
23+
import java.util.List;
24+
import java.util.UUID;
25+
import java.util.function.ObjIntConsumer;
26+
import java.util.function.Predicate;
27+
import java.util.stream.IntStream;
28+
29+
public class LoopAgentsBuilder {
30+
31+
private final FuncTaskItemListBuilder funcDelegate;
32+
private final ForTaskFunction forTask;
33+
34+
LoopAgentsBuilder() {
35+
this.forTask = new ForTaskFunction();
36+
this.forTask.setFor(new ForTaskConfiguration());
37+
this.funcDelegate = new FuncTaskItemListBuilder();
38+
}
39+
40+
private static <E> void forEachIndexed(List<E> list, ObjIntConsumer<E> consumer) {
41+
IntStream.range(0, list.size()).forEach(i -> consumer.accept(list.get(i), i));
42+
}
43+
44+
public LoopAgentsBuilder subAgents(String baseName, Object... agents) {
45+
List<AgentExecutor> execs = AgentAdapters.toExecutors(agents);
46+
forEachIndexed(
47+
execs,
48+
(exec, idx) ->
49+
funcDelegate.callFn(
50+
baseName + "-" + idx, fn -> fn.function(AgentAdapters.toFunction(exec))));
51+
return this;
52+
}
53+
54+
public LoopAgentsBuilder subAgents(Object... agents) {
55+
return this.subAgents("agent-" + UUID.randomUUID(), agents);
56+
}
57+
58+
public LoopAgentsBuilder maxIterations(int maxIterations) {
59+
this.forTask.withCollection(ignored -> IntStream.range(0, maxIterations).boxed().toList());
60+
return this;
61+
}
62+
63+
public LoopAgentsBuilder exitCondition(Predicate<Cognisphere> exitCondition) {
64+
this.forTask.withWhile(AgentAdapters.toWhile(exitCondition));
65+
return this;
66+
}
67+
68+
public ForTaskFunction build() {
69+
this.forTask.setDo(this.funcDelegate.build());
70+
return this.forTask;
71+
}
72+
}
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
/*
2+
* Copyright 2020-Present The Serverless Workflow Specification Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.serverlessworkflow.fluent.agentic;
17+
18+
import static org.assertj.core.api.Assertions.assertThat;
19+
20+
import io.serverlessworkflow.api.types.Task;
21+
import io.serverlessworkflow.api.types.TaskItem;
22+
import io.serverlessworkflow.api.types.func.CallTaskJava;
23+
import io.serverlessworkflow.api.types.func.ForTaskFunction;
24+
import java.util.List;
25+
import org.junit.jupiter.api.DisplayName;
26+
import org.junit.jupiter.api.Test;
27+
28+
/** Structural tests for AgentTaskItemListBuilder. */
29+
class AgentTaskItemListBuilderTest {
30+
31+
@Test
32+
@DisplayName("agent(name,obj) adds a CallTaskJava-backed TaskItem")
33+
void testAgentAddsCallTask() {
34+
AgentTaskItemListBuilder b = new AgentTaskItemListBuilder();
35+
Agents.MovieExpert agent = AgentsUtils.newMovieExpert();
36+
37+
b.agent("my-agent", agent);
38+
39+
List<TaskItem> items = b.build();
40+
assertThat(items).hasSize(1);
41+
TaskItem item = items.get(0);
42+
assertThat(item.getName()).isEqualTo("my-agent");
43+
44+
Task task = item.getTask();
45+
assertThat(task.getCallTask()).isInstanceOf(CallTaskJava.class);
46+
}
47+
48+
@Test
49+
@DisplayName("sequence(name, agents...) expands to N CallTask items, in order")
50+
void testSequence() {
51+
AgentTaskItemListBuilder b = new AgentTaskItemListBuilder();
52+
Agents.MovieExpert a1 = AgentsUtils.newMovieExpert();
53+
Agents.MovieExpert a2 = AgentsUtils.newMovieExpert();
54+
Agents.MovieExpert a3 = AgentsUtils.newMovieExpert();
55+
56+
b.sequence("seq", a1, a2, a3);
57+
58+
List<TaskItem> items = b.build();
59+
assertThat(items).hasSize(3);
60+
assertThat(items.get(0).getName()).isEqualTo("seq-0");
61+
assertThat(items.get(1).getName()).isEqualTo("seq-1");
62+
assertThat(items.get(2).getName()).isEqualTo("seq-2");
63+
64+
// All must be call tasks
65+
items.forEach(it -> assertThat(it.getTask().getCallTask().get()).isNotNull());
66+
}
67+
68+
@Test
69+
@DisplayName("loop(name, builder) produces a ForTaskFunction with inner call tasks")
70+
void testLoop() {
71+
AgentTaskItemListBuilder b = new AgentTaskItemListBuilder();
72+
Agents.MovieExpert scorer = AgentsUtils.newMovieExpert();
73+
Agents.MovieExpert editor = AgentsUtils.newMovieExpert();
74+
75+
b.loop("rev-loop", loop -> loop.subAgents("inner", scorer, editor));
76+
77+
List<TaskItem> items = b.build();
78+
assertThat(items).hasSize(1);
79+
80+
TaskItem loopItem = items.get(0);
81+
ForTaskFunction forFn = (ForTaskFunction) loopItem.getTask().getForTask();
82+
assertThat(forFn).isNotNull();
83+
assertThat(forFn.getDo()).hasSize(2); // scorer + editor inside
84+
assertThat(forFn.getDo().get(0).getTask().getCallTask().get()).isNotNull();
85+
}
86+
}

fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/AgenticWorkflowBuilderTest.java

Lines changed: 57 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package io.serverlessworkflow.fluent.agentic;
1717

18+
import static io.serverlessworkflow.fluent.agentic.AgentsUtils.newMovieExpert;
1819
import static io.serverlessworkflow.fluent.agentic.Models.BASE_MODEL;
1920
import static org.assertj.core.api.Assertions.assertThat;
2021
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -23,9 +24,14 @@
2324
import static org.mockito.Mockito.spy;
2425

2526
import dev.langchain4j.agentic.AgentServices;
27+
import dev.langchain4j.agentic.Cognisphere;
28+
import io.serverlessworkflow.api.types.Task;
29+
import io.serverlessworkflow.api.types.TaskItem;
2630
import io.serverlessworkflow.api.types.Workflow;
2731
import io.serverlessworkflow.api.types.func.CallJava;
28-
import org.junit.jupiter.api.DisplayName;
32+
import io.serverlessworkflow.api.types.func.ForTaskFunction;
33+
import java.util.concurrent.atomic.AtomicInteger;
34+
import java.util.function.Predicate;
2935
import org.junit.jupiter.api.Test;
3036

3137
public class AgenticWorkflowBuilderTest {
@@ -69,7 +75,6 @@ void sequenceAgents() {
6975
}
7076

7177
@Test
72-
@DisplayName("Mix spec verbs with agent()")
7378
void mixSpecAndAgent() {
7479
Workflow wf =
7580
AgenticWorkflowBuilder.workflow("mixFlow")
@@ -86,11 +91,55 @@ void mixSpecAndAgent() {
8691
assertThat(wf.getDo().get(2).getTask().getSetTask()).isNotNull();
8792
}
8893

89-
private Agents.MovieExpert newMovieExpert() {
90-
return spy(
91-
AgentServices.agentBuilder(Agents.MovieExpert.class)
92-
.outputName("movies")
93-
.chatModel(BASE_MODEL)
94-
.build());
94+
@Test
95+
void loopOnlyAgents() {
96+
Agents.MovieExpert expert = newMovieExpert();
97+
98+
Workflow wf =
99+
AgenticWorkflowBuilder.workflow().tasks(d -> d.loop(l -> l.subAgents(expert))).build();
100+
101+
assertNotNull(wf);
102+
assertThat(wf.getDo()).hasSize(1);
103+
104+
TaskItem ti = wf.getDo().get(0);
105+
Task t = ti.getTask();
106+
assertThat(t.getForTask()).isInstanceOf(ForTaskFunction.class);
107+
108+
ForTaskFunction fn = (ForTaskFunction) t.getForTask();
109+
assertNotNull(fn.getDo());
110+
assertThat(fn.getDo()).hasSize(1);
111+
assertNotNull(fn.getDo().get(0).getTask().getCallTask().get());
112+
}
113+
114+
@Test
115+
void loopWithMaxIterationsAndExitCondition() {
116+
Agents.MovieExpert expert = newMovieExpert();
117+
118+
AtomicInteger max = new AtomicInteger(4);
119+
Predicate<Cognisphere> exit =
120+
cog -> {
121+
// stop when we already have at least one movie picked in state
122+
var movies = cog.readState("movies", null);
123+
return movies != null;
124+
};
125+
126+
Workflow wf =
127+
AgenticWorkflowBuilder.workflow("loop-ctrl")
128+
.tasks(
129+
d ->
130+
d.loop(
131+
"refineMovies",
132+
l ->
133+
l.maxIterations(max.get())
134+
.exitCondition(exit)
135+
.subAgents("picker", expert)))
136+
.build();
137+
138+
TaskItem ti = wf.getDo().get(0);
139+
ForTaskFunction fn = (ForTaskFunction) ti.getTask().getForTask();
140+
141+
assertNotNull(fn.getCollection(), "Synthetic collection should exist for maxIterations");
142+
assertNotNull(fn.getWhilePredicate(), "While predicate set from exitCondition");
143+
assertThat(fn.getDo()).hasSize(1);
95144
}
96145
}

fluent/agentic/src/test/java/io/serverlessworkflow/fluent/agentic/Agents.java

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,11 +26,11 @@ interface MovieExpert {
2626

2727
@UserMessage(
2828
"""
29-
You are a great evening planner.
30-
Propose a list of 3 movies matching the given mood.
31-
The mood is {mood}.
32-
Provide a list with the 3 items and nothing else.
33-
""")
29+
You are a great evening planner.
30+
Propose a list of 3 movies matching the given mood.
31+
The mood is {mood}.
32+
Provide a list with the 3 items and nothing else.
33+
""")
3434
@Agent
3535
List<String> findMovie(@V("mood") String mood);
3636
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/*
2+
* Copyright 2020-Present The Serverless Workflow Specification Authors
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package io.serverlessworkflow.fluent.agentic;
17+
18+
import static io.serverlessworkflow.fluent.agentic.Models.BASE_MODEL;
19+
import static org.mockito.Mockito.spy;
20+
21+
import dev.langchain4j.agentic.AgentServices;
22+
23+
public final class AgentsUtils {
24+
25+
private AgentsUtils() {}
26+
27+
public static Agents.MovieExpert newMovieExpert() {
28+
return spy(
29+
AgentServices.agentBuilder(Agents.MovieExpert.class)
30+
.outputName("movies")
31+
.chatModel(BASE_MODEL)
32+
.build());
33+
}
34+
}

0 commit comments

Comments
 (0)