Skip to content

Commit 2378579

Browse files
committed
Added annotations for retry for qos provider, and also properties, with nested for action names gh-1221
Signed-off-by: hayden.rear <[email protected]>
1 parent 99d331f commit 2378579

File tree

11 files changed

+631
-3
lines changed

11 files changed

+631
-3
lines changed

embabel-agent-api/src/main/kotlin/com/embabel/agent/api/annotation/annotations.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ package com.embabel.agent.api.annotation
1717

1818
import com.embabel.agent.api.common.PlannerType
1919
import com.embabel.agent.core.IoBinding
20+
import com.embabel.agent.core.Retry
2021
import com.embabel.common.core.types.Semver.Companion.DEFAULT_VERSION
2122
import com.embabel.common.core.types.ZeroToOne
2223
import org.springframework.core.annotation.AliasFor
@@ -181,6 +182,7 @@ annotation class Action(
181182
val toolGroups: Array<String> = [],
182183
val toolGroupRequirements: Array<ToolGroup> = [],
183184
val trigger: KClass<*> = Unit::class,
185+
val retry: Array<Retry> = []
184186
)
185187

186188
/**

embabel-agent-api/src/main/kotlin/com/embabel/agent/api/annotation/support/ActionMethodManager.kt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ interface ActionMethodManager {
4444
*/
4545
val argumentResolvers: List<ActionMethodArgumentResolver>
4646

47+
val actionQosProvider: ActionQosProvider
48+
4749
/**
4850
* Create an Action from a method
4951
* @param method the method to create an action from

embabel-agent-api/src/main/kotlin/com/embabel/agent/api/annotation/support/DefaultActionMethodManager.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ import kotlin.reflect.jvm.kotlinFunction
4848
@Component
4949
internal class DefaultActionMethodManager(
5050
val nameGenerator: MethodDefinedOperationNameGenerator = MethodDefinedOperationNameGenerator(),
51-
val actionQosProvider: ActionQosProvider = DefaultActionQosProvider(),
51+
override val actionQosProvider: ActionQosProvider = DefaultActionQosProvider(),
5252
override val argumentResolvers: List<ActionMethodArgumentResolver> = listOf(
5353
ProcessContextArgumentResolver(),
5454
OperationContextArgumentResolver(),

embabel-agent-api/src/main/kotlin/com/embabel/agent/api/annotation/support/DefaultActionQosProvider.kt

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,14 +15,44 @@
1515
*/
1616
package com.embabel.agent.api.annotation.support
1717

18+
import com.embabel.agent.api.annotation.Action
19+
import com.embabel.agent.api.annotation.Agent
1820
import com.embabel.agent.core.ActionQos
21+
import com.embabel.agent.spi.config.spring.AgentPlatformProperties
22+
import org.springframework.stereotype.Component
1923
import java.lang.reflect.Method
2024

21-
class DefaultActionQosProvider : ActionQosProvider {
25+
@Component
26+
class DefaultActionQosProvider(
27+
val perActionQosProperties: AgentPlatformProperties.ActionQosProperties = AgentPlatformProperties.ActionQosProperties()
28+
) : ActionQosProvider {
29+
2230
override fun provideActionQos(
2331
method: Method,
2432
instance: Any
2533
): ActionQos {
26-
return ActionQos()
34+
35+
val defaultActionQos = perActionQosProperties.default.toActionQos()
36+
37+
val props = instance.javaClass.getAnnotation(Agent::class.java)?.let {
38+
perActionQosProperties.agents[it.name]?.get(method.name)?.toActionQos(defaultActionQos)
39+
?: perActionQosProperties.default.toActionQos(defaultActionQos)
40+
} ?: perActionQosProperties.default.toActionQos(defaultActionQos)
41+
42+
if (method.isAnnotationPresent(Action::class.java)
43+
&& method.getAnnotation(Action::class.java).retry.isNotEmpty()) {
44+
return method.getAnnotation(Action::class.java).retry.first()
45+
.let { retryAction ->
46+
ActionQos(
47+
retryAction.maxAttempts.firstOrNull() ?: props.maxAttempts,
48+
retryAction.backoffMillis.firstOrNull() ?: props.backoffMillis,
49+
retryAction.backoffMultiplier.firstOrNull() ?: props.backoffMultiplier,
50+
retryAction.backoffMaxInterval.firstOrNull() ?: props.backoffMaxInterval,
51+
retryAction.idempotent.firstOrNull() ?: props.idempotent
52+
)
53+
}
54+
}
55+
56+
return props
2757
}
2858
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
/*
2+
* Copyright 2024-2025 Embabel Software, Inc.
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 com.embabel.agent.core
17+
18+
/**
19+
* Used as the first priority in overriding retry behavior for a particular method Action.
20+
*/
21+
annotation class Retry(
22+
val maxAttempts: IntArray = [],
23+
val backoffMillis: LongArray = [],
24+
val backoffMultiplier: DoubleArray = [],
25+
val backoffMaxInterval: LongArray = [],
26+
val idempotent: BooleanArray = [],
27+
)

embabel-agent-api/src/main/kotlin/com/embabel/agent/spi/config/spring/AgentPlatformProperties.kt

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
*/
1616
package com.embabel.agent.spi.config.spring
1717

18+
import com.embabel.agent.core.ActionQos
1819
import org.springframework.boot.context.properties.ConfigurationProperties
1920
import org.springframework.boot.context.properties.NestedConfigurationProperty
2021

@@ -67,6 +68,9 @@ class AgentPlatformProperties {
6768
@field:NestedConfigurationProperty
6869
var test: TestConfig = TestConfig()
6970

71+
@field:NestedConfigurationProperty
72+
var actionQos: ActionQosProperties = ActionQosProperties()
73+
7074
/**
7175
* Agent Process Type
7276
*/
@@ -282,4 +286,40 @@ class AgentPlatformProperties {
282286
*/
283287
var mockMode: Boolean = true
284288
}
289+
290+
/**
291+
* Configuration of retry by @Action
292+
*/
293+
@ConfigurationProperties(prefix = "embabel.agent.platform.action-qos")
294+
class ActionQosProperties {
295+
296+
data class ActionProperties(
297+
var maxAttempts: Int? = null,
298+
var backoffMillis: Long? = null,
299+
var backoffMultiplier: Double? = null,
300+
var backoffMaxInterval: Long? = null,
301+
var idempotent: Boolean? = null,
302+
) {
303+
fun toActionQos(defaultAction : ActionQos = ActionQos()): ActionQos {
304+
return ActionQos(
305+
maxAttempts = maxAttempts ?: defaultAction.maxAttempts,
306+
backoffMillis = backoffMillis ?: defaultAction.backoffMillis,
307+
backoffMultiplier = backoffMultiplier ?: defaultAction.backoffMultiplier,
308+
backoffMaxInterval = backoffMaxInterval ?: defaultAction.backoffMaxInterval,
309+
idempotent = idempotent ?: defaultAction.idempotent
310+
)
311+
}
312+
}
313+
314+
/**
315+
* Fallback retry properties for @Action
316+
*/
317+
var default: ActionProperties = ActionProperties()
318+
319+
/**
320+
* Retry properties keyed by agent name and then @Action method name
321+
*/
322+
var agents: Map<String, Map<String, ActionProperties>> = LinkedHashMap()
323+
324+
}
285325
}
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
/*
2+
* Copyright 2024-2025 Embabel Software, Inc.
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 com.embabel.agent.api.annotation.support;
17+
18+
import com.embabel.agent.api.annotation.AchievesGoal;
19+
import com.embabel.agent.api.annotation.Action;
20+
import com.embabel.agent.api.annotation.Agent;
21+
import com.embabel.agent.api.common.PlannerType;
22+
import com.embabel.agent.core.ProcessOptions;
23+
import com.embabel.agent.core.Retry;
24+
import com.embabel.agent.test.integration.IntegrationTestUtils;
25+
import org.junit.jupiter.api.Assertions;
26+
import org.junit.jupiter.api.Test;
27+
28+
import java.util.Map;
29+
import java.util.concurrent.atomic.AtomicInteger;
30+
31+
import static org.junit.jupiter.api.Assertions.*;
32+
33+
/**
34+
* Java tests for @Retry annotation.
35+
*/
36+
class RetryAnnotationJavaTest {
37+
38+
@Test
39+
void retryMethodFailsOnlyOnceSucceedsSecond() {
40+
var reader = new AgentMetadataReader();
41+
var instance = new JavaAgentWithTwoRetryActions();
42+
var metadata = reader.createAgentMetadata(instance);
43+
44+
assertNotNull(metadata);
45+
var agent = (com.embabel.agent.core.Agent) metadata;
46+
47+
var ap = IntegrationTestUtils.dummyAgentPlatform();
48+
var agentProcess = ap.createAgentProcess(
49+
agent,
50+
ProcessOptions.DEFAULT.withPlannerType(PlannerType.UTILITY),
51+
Map.of(
52+
"input", new JavaRetryTestInput("test")
53+
)
54+
);
55+
56+
57+
Assertions.assertDoesNotThrow(() -> agentProcess.run().resultOfType(JavaRetryTestOutput.class));
58+
59+
assertEquals(2, instance.retryInvocations.get(), "Retryable method should have been invoked");
60+
}
61+
62+
@Test
63+
void retryMethodFailsOnlyOnce() {
64+
var reader = new AgentMetadataReader();
65+
var instance = new JavaAgentWithRetryMethod();
66+
var metadata = reader.createAgentMetadata(instance);
67+
68+
assertNotNull(metadata);
69+
var agent = (com.embabel.agent.core.Agent) metadata;
70+
71+
var ap = IntegrationTestUtils.dummyAgentPlatform();
72+
var agentProcess = ap.createAgentProcess(
73+
agent,
74+
ProcessOptions.DEFAULT.withPlannerType(PlannerType.UTILITY),
75+
Map.of(
76+
"input", new JavaRetryTestInput("test")
77+
)
78+
);
79+
80+
81+
Assertions.assertThrows(RuntimeException.class, () -> agentProcess.run().resultOfType(JavaRetryTestOutput.class));
82+
83+
assertEquals(1, instance.retryInvocationCount.get(), "Retryable method should have been invoked");
84+
}
85+
86+
}
87+
88+
/**
89+
* Simple domain class for testing.
90+
*/
91+
record JavaRetryTestInput(String value) {
92+
}
93+
94+
/**
95+
* Simple output class for testing.
96+
*/
97+
record JavaRetryTestOutput(String result) {
98+
}
99+
100+
/**
101+
* Agent with @Cost method that uses nullable domain parameter.
102+
*/
103+
@Agent(description = "Java agent with 1 retry", planner = PlannerType.UTILITY)
104+
class JavaAgentWithRetryMethod {
105+
106+
final AtomicInteger retryInvocationCount = new AtomicInteger(0);
107+
108+
@Action(retry = @Retry(maxAttempts = 1, backoffMillis = 1L))
109+
public JavaRetryTestOutput perform(JavaRetryTestInput input) {
110+
retryInvocationCount.incrementAndGet();
111+
throw new RuntimeException("Failed on purpose!");
112+
}
113+
}
114+
115+
/**
116+
* Agent with two actions with different dynamic costs.
117+
*/
118+
@Agent(description = "Java agent with two dynamic cost actions", planner = PlannerType.GOAP)
119+
class JavaAgentWithTwoRetryActions {
120+
121+
final AtomicInteger retryInvocations = new AtomicInteger(0);
122+
123+
@AchievesGoal(description = "Process the input")
124+
@Action(retry = @Retry(maxAttempts = 2, backoffMillis = 1L))
125+
public JavaRetryTestOutput firstAction(JavaRetryTestInput input) {
126+
retryInvocations.incrementAndGet();
127+
if (retryInvocations.get() == 1)
128+
throw new RuntimeException("Failed!");
129+
130+
return new JavaRetryTestOutput("Success!");
131+
132+
}
133+
134+
135+
}

0 commit comments

Comments
 (0)