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
Original file line number Diff line number Diff line change
Expand Up @@ -525,17 +525,43 @@ private AutonomousAgentTools() {}

/**
* Creates a {@link ToolInvocationRequest} for the {@code complete_task} tool, with the given
* result JSON. The JSON must conform to the task's result type schema.
* raw result JSON string. The JSON must conform to the task's result type schema. Use {@link
* #completeTask(Object)} for type-safe serialization of result objects.
*/
public static ToolInvocationRequest completeTask(String resultJson) {
public static ToolInvocationRequest completeTaskJson(String resultJson) {
if (resultJson == null
|| !resultJson.trim().startsWith("{")
|| !resultJson.trim().endsWith("}")) {
throw new IllegalArgumentException(
"resultJson must be a JSON object (starting with '{' and ending with '}'). "
+ "For type-safe serialization, use completeTask(Object) instead.");
}
return new ToolInvocationRequest(COMPLETE_TASK, resultJson);
}

/**
* Creates a {@link ToolInvocationRequest} for the {@code complete_task} tool, serializing the
* given result object to JSON.
* given result object to JSON. For non-object result types (String, Integer, Number, Boolean,
* and collections/arrays), wraps the value in a {@code {"result": ...}} JSON object matching
* the runtime's expected format. For object types (records, POJOs), the object's own properties
* become the tool arguments directly (passthrough).
*/
public static ToolInvocationRequest completeTask(Object result) {
if (result instanceof String s) {
return new ToolInvocationRequest(COMPLETE_TASK, "{\"result\":" + toJsonString(s) + "}");
}
if (result instanceof Number || result instanceof Boolean) {
return new ToolInvocationRequest(COMPLETE_TASK, "{\"result\":" + result + "}");
}
if (result instanceof java.util.Collection || result.getClass().isArray()) {
try {
return new ToolInvocationRequest(
COMPLETE_TASK,
"{\"result\":" + JsonSupport.getObjectMapper().writeValueAsString(result) + "}");
} catch (JsonProcessingException e) {
throw new IllegalArgumentException("Failed to serialize task result to JSON", e);
}
}
try {
return new ToolInvocationRequest(
COMPLETE_TASK, JsonSupport.getObjectMapper().writeValueAsString(result));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public void afterEach() {

@Test
public void shouldGetAgentState() {
testAgentModel.fixedResponse(completeTask("{\"value\":\"done\",\"score\":1}"));
testAgentModel.fixedResponse(completeTask(new TestTasks.TestResult("done", 1)));

var agentId = UUID.randomUUID().toString();
var agentClient = componentClient.forAutonomousAgent(TestAutonomousAgent.class, agentId);
Expand All @@ -69,7 +69,7 @@ public void shouldGetAgentState() {

@Test
public void shouldPauseAndResumeAgent() {
testAgentModel.fixedResponse(completeTask("{\"value\":\"done after resume\",\"score\":1}"));
testAgentModel.fixedResponse(completeTask(new TestTasks.TestResult("done after resume", 1)));

var agentId = UUID.randomUUID().toString();
var agentClient = componentClient.forAutonomousAgent(TestAutonomousAgent.class, agentId);
Expand Down Expand Up @@ -154,7 +154,7 @@ public void shouldReceiveLifecycleNotifications() {
testAgentModel.fixedResponse(
new AiResponse(
"",
List.of(completeTask("{\"value\":\"done\",\"score\":1}")),
List.of(completeTask(new TestTasks.TestResult("done", 1))),
Optional.of(new Agent.TokenUsage(150, 42))));

var agentId = UUID.randomUUID().toString();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,15 +64,15 @@ public void shouldDelegateToWorkerAndSynthesizeResult() {

workerModel.fixedResponse(
completeTask(
"{\"topic\":\"Quantum Computing\",\"findings\":\"Qubits enable parallel"
+ " computation.\"}"));
new TestTasks.FindingsResult(
"Quantum Computing", "Qubits enable parallel computation.")));

coordinatorModel
.whenMessage(msg -> msg.contains("Continue working"))
.reply(
completeTask(
"{\"title\":\"Quantum Computing Summary\",\"summary\":\"Qubits enable parallel"
+ " computation.\"}"));
new TestTasks.ResearchResult(
"Quantum Computing Summary", "Qubits enable parallel computation.")));

var taskId =
componentClient
Expand All @@ -97,8 +97,7 @@ public void shouldHandoffToSpecialist() {
handoffTo(SpecialistTestAgent.class, "Customer has a billing dispute."));

specialistModel.fixedResponse(
completeTask(
"{\"category\":\"billing\",\"resolution\":\"Refund issued.\",\"resolved\":true}"));
completeTask(new TestTasks.SupportResolution("billing", "Refund issued.", true)));

var taskId =
componentClient
Expand Down Expand Up @@ -127,7 +126,7 @@ public void shouldDelegateToRequestBasedAgent() {

requestDelegatingModel
.whenMessage(msg -> msg.contains("Continue working"))
.reply(completeTask("{\"value\":\"Claim verified.\",\"score\":90}"));
.reply(completeTask(new TestTasks.TestResult("Claim verified.", 90)));

var taskId =
componentClient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ public void shouldRunDirectedConversation() {
case END_CONVERSATION ->
new AiResponse(
completeTask(
"{\"topic\":\"Directed debate\",\"conclusion\":\"Agreement reached.\"}"));
new TestTasks.ModerationResult("Directed debate", "Agreement reached.")));
case COMPLETE_TASK -> new AiResponse("Done.");
default -> new AiResponse("Acknowledged.");
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,8 @@ public void shouldRunScriptedConversation() {
// Conversation completed
return new AiResponse(
completeTask(
"{\"topic\":\"Test topic\",\"conclusion\":\"Balanced conclusion"
+ " reached.\"}"));
new TestTasks.ModerationResult(
"Test topic", "Balanced conclusion reached.")));
}
return new AiResponse("Acknowledged.");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package akkajavasdk.components.agent.autonomous;

import static akka.javasdk.testkit.TestModelProvider.AutonomousAgentTools.completeTask;
import static akka.javasdk.testkit.TestModelProvider.AutonomousAgentTools.completeTaskJson;
import static akka.javasdk.testkit.TestModelProvider.AutonomousAgentTools.delegateTo;
import static akka.javasdk.testkit.TestModelProvider.AutonomousAgentTools.failTask;
import static org.assertj.core.api.Assertions.assertThat;
Expand Down Expand Up @@ -62,7 +63,7 @@ public void afterEach() {

@Test
public void shouldCompleteTaskWithTypedResult() {
testAgentModel.fixedResponse(completeTask("{\"value\":\"42 is the answer.\",\"score\":95}"));
testAgentModel.fixedResponse(completeTask(new TestTasks.TestResult("42 is the answer.", 95)));

var taskId =
componentClient
Expand All @@ -85,7 +86,7 @@ public void shouldCompleteTaskWithTypedResult() {

@Test
public void shouldCompleteTaskWithStringResult() {
testAgentModel.fixedResponse(completeTask("{\"result\":\"The capital of France is Paris.\"}"));
testAgentModel.fixedResponse(completeTask("The capital of France is Paris."));

var taskId =
componentClient
Expand All @@ -102,6 +103,64 @@ public void shouldCompleteTaskWithStringResult() {
});
}

@Test
public void shouldCompleteTaskWithJsonStringResult() {
testAgentModel.fixedResponse(
completeTaskJson("{\"result\":\"The capital of France is Paris.\"}"));

var taskId =
componentClient
.forAutonomousAgent(TestAutonomousAgent.class, UUID.randomUUID().toString())
.runSingleTask(TestTasks.STRING_TASK.instructions("What is the capital of France?"));

Awaitility.await()
.ignoreExceptions()
.atMost(10, TimeUnit.SECONDS)
.untilAsserted(
() -> {
var snapshot = componentClient.forTask(taskId).get(TestTasks.STRING_TASK);
assertThat(snapshot.result()).isEqualTo("The capital of France is Paris.");
});
}

@Test
public void shouldCompleteTaskWithIntegerResult() {
testAgentModel.fixedResponse(completeTask(42));

var taskId =
componentClient
.forAutonomousAgent(TestAutonomousAgent.class, UUID.randomUUID().toString())
.runSingleTask(TestTasks.INTEGER_TASK.instructions("What is the answer?"));

Awaitility.await()
.ignoreExceptions()
.atMost(10, TimeUnit.SECONDS)
.untilAsserted(
() -> {
var snapshot = componentClient.forTask(taskId).get(TestTasks.INTEGER_TASK);
assertThat(snapshot.result()).isEqualTo(42);
});
}

@Test
public void shouldCompleteTaskWithBooleanResult() {
testAgentModel.fixedResponse(completeTask(true));

var taskId =
componentClient
.forAutonomousAgent(TestAutonomousAgent.class, UUID.randomUUID().toString())
.runSingleTask(TestTasks.BOOLEAN_TASK.instructions("Is the sky blue?"));

Awaitility.await()
.ignoreExceptions()
.atMost(10, TimeUnit.SECONDS)
.untilAsserted(
() -> {
var snapshot = componentClient.forTask(taskId).get(TestTasks.BOOLEAN_TASK);
assertThat(snapshot.result()).isTrue();
});
}

@Test
public void shouldFailTask() {
testAgentModel.fixedResponse(failTask("Cannot answer this question."));
Expand Down Expand Up @@ -132,7 +191,8 @@ public void shouldUseToolsThenCompleteTask() {
.whenToolResult(result -> result.content().equals("2025-01-15"))
.thenReply(
result ->
new AiResponse(completeTask("{\"value\":\"Today is 2025-01-15.\",\"score\":100}")));
new AiResponse(
completeTask(new TestTasks.TestResult("Today is 2025-01-15.", 100))));

var taskId =
componentClient
Expand Down Expand Up @@ -171,7 +231,8 @@ public void shouldUnwrapDelegatedCommandPayload() {
requestDelegatingModel
.whenToolResult(result -> result.content().contains("sky is indeed blue"))
.thenReply(
result -> new AiResponse(completeTask("{\"value\":\"Claim verified.\",\"score\":90}")));
result ->
new AiResponse(completeTask(new TestTasks.TestResult("Claim verified.", 90))));

var taskId =
componentClient
Expand Down Expand Up @@ -213,7 +274,8 @@ public void shouldUnwrapTypedParameterWhenDelegatingToRequestBasedAgent() {
requestDelegatingModel
.whenToolResult(result -> result.content().contains("Confirmed"))
.thenReply(
result -> new AiResponse(completeTask("{\"value\":\"Fact confirmed.\",\"score\":95}")));
result ->
new AiResponse(completeTask(new TestTasks.TestResult("Fact confirmed.", 95))));

var taskId =
componentClient
Expand Down Expand Up @@ -245,14 +307,14 @@ public void shouldDelegateWithTaskTemplate() {

// Worker completes the delegated work item
teamWorkerModel.fixedResponse(
completeTask("{\"item\":\"Login page\",\"output\":\"Implemented OAuth login flow.\"}"));
completeTask(new TestTasks.WorkItemResult("Login page", "Implemented OAuth login flow.")));

// Coordinator synthesizes worker result into research result
templateDelegatingModel
.whenMessage(msg -> msg.contains("Continue working"))
.reply(
completeTask(
"{\"title\":\"Login Feature\",\"summary\":\"OAuth login flow implemented.\"}"));
new TestTasks.ResearchResult("Login Feature", "OAuth login flow implemented.")));

var taskId =
componentClient
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,7 @@ public void shouldCreateTeamAndDisband() {
case GET_TEAM_STATUS -> new AiResponse(disbandTeam());
case DISBAND_TEAM ->
new AiResponse(
completeTask(
"{\"summary\":\"Team created and disbanded.\",\"tasksCompleted\":0}"));
completeTask(new TestTasks.PlanResult("Team created and disbanded.", 0)));
default -> new AiResponse("Continuing work.");
};
}
Expand Down Expand Up @@ -146,8 +145,7 @@ public void shouldManageBacklogTasks() {
case GET_MANAGED_BACKLOG_STATUS -> new AiResponse(cancelUnclaimedTasksFromBacklog());
case CANCEL_UNCLAIMED_TASKS_FROM_BACKLOG -> new AiResponse(disbandTeam());
case DISBAND_TEAM ->
new AiResponse(
completeTask("{\"summary\":\"Backlog managed.\",\"tasksCompleted\":0}"));
new AiResponse(completeTask(new TestTasks.PlanResult("Backlog managed.", 0)));
default -> new AiResponse("Continuing work.");
};
}
Expand Down Expand Up @@ -193,8 +191,7 @@ public void shouldCreateBacklogTaskWithTemplateParams() {
case GET_MANAGED_BACKLOG_STATUS -> new AiResponse(disbandTeam());
case DISBAND_TEAM ->
new AiResponse(
completeTask(
"{\"summary\":\"Template task created.\",\"tasksCompleted\":0}"));
completeTask(new TestTasks.PlanResult("Template task created.", 0)));
default -> new AiResponse("Continuing work.");
};
}
Expand Down Expand Up @@ -244,8 +241,7 @@ public void shouldClaimAndCompleteBacklogTask() {
yield new AiResponse(getManagedBacklogStatus());
}
case DISBAND_TEAM ->
new AiResponse(
completeTask("{\"summary\":\"Work completed.\",\"tasksCompleted\":1}"));
new AiResponse(completeTask(new TestTasks.PlanResult("Work completed.", 1)));
default -> new AiResponse("Continuing work.");
};
}
Expand All @@ -266,7 +262,7 @@ public void shouldClaimAndCompleteBacklogTask() {
}
case CLAIM_TASK ->
new AiResponse(
completeTask("{\"item\":\"Feature X\",\"output\":\"Implemented.\"}"));
completeTask(new TestTasks.WorkItemResult("Feature X", "Implemented.")));
case COMPLETE_TASK -> new AiResponse(getBacklogStatus());
default -> new AiResponse(getBacklogStatus());
};
Expand Down Expand Up @@ -315,8 +311,7 @@ public void shouldReleaseBacklogTask() {
yield new AiResponse(getManagedBacklogStatus());
}
case DISBAND_TEAM ->
new AiResponse(
completeTask("{\"summary\":\"Release tested.\",\"tasksCompleted\":1}"));
new AiResponse(completeTask(new TestTasks.PlanResult("Release tested.", 1)));
default -> new AiResponse("Continuing work.");
};
}
Expand Down Expand Up @@ -346,7 +341,7 @@ public void shouldReleaseBacklogTask() {
// Second claim: complete it
yield new AiResponse(
completeTask(
"{\"item\":\"Releasable task\",\"output\":\"Done after release.\"}"));
new TestTasks.WorkItemResult("Releasable task", "Done after release.")));
}
case RELEASE_TASK -> new AiResponse(getBacklogStatus());
case COMPLETE_TASK -> new AiResponse(getBacklogStatus());
Expand Down Expand Up @@ -396,8 +391,7 @@ public void shouldTransferBacklogTask() {
yield new AiResponse(getManagedBacklogStatus());
}
case DISBAND_TEAM ->
new AiResponse(
completeTask("{\"summary\":\"Transfer tested.\",\"tasksCompleted\":1}"));
new AiResponse(completeTask(new TestTasks.PlanResult("Transfer tested.", 1)));
default -> new AiResponse("Continuing work.");
};
}
Expand All @@ -424,14 +418,14 @@ yield new AiResponse(
}
yield new AiResponse(
completeTask(
"{\"item\":\"Transferable task\",\"output\":\"Completed after"
+ " transfer.\"}"));
new TestTasks.WorkItemResult(
"Transferable task", "Completed after transfer.")));
}
case TRANSFER_TASK ->
new AiResponse(
completeTask(
"{\"item\":\"Transferable task\",\"output\":\"Completed after"
+ " transfer.\"}"));
new TestTasks.WorkItemResult(
"Transferable task", "Completed after transfer.")));
case COMPLETE_TASK -> new AiResponse(getBacklogStatus());
default -> new AiResponse(getBacklogStatus());
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ public TestAutonomousAgent(ComponentClient componentClient) {
public AgentDefinition definition() {
return define()
.goal("Test agent")
.capability(TaskAcceptance.of(TestTasks.TEST_TASK, TestTasks.STRING_TASK));
.capability(
TaskAcceptance.of(
TestTasks.TEST_TASK,
TestTasks.STRING_TASK,
TestTasks.INTEGER_TASK,
TestTasks.BOOLEAN_TASK));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,16 @@ public record ModerationResult(String topic, String conclusion) {}
public static final Task<String> STRING_TASK =
Task.define("String task").description("A task with string result");

public static final Task<Integer> INTEGER_TASK =
Task.define("Integer task")
.description("A task with integer result")
.resultConformsTo(Integer.class);

public static final Task<Boolean> BOOLEAN_TASK =
Task.define("Boolean task")
.description("A task with boolean result")
.resultConformsTo(Boolean.class);

public static final Task<ResearchResult> RESEARCH =
Task.define("Research")
.description("Produce a research summary")
Expand Down
Loading
Loading