From 6fdd8522e23a8cb780a1773b9da4a32ff6cfee45 Mon Sep 17 00:00:00 2001 From: XianReallyHot-ZZH <741788769@qq.com> Date: Sun, 18 Oct 2026 01:48:32 +0800 Subject: [PATCH 1/2] feat(agent): add task completion hook with bounded exits and explicit terminal states (T36) Introduce the opt-in TaskCompletionHook: a run whose completion predicate (default: unfinished todos) reports unfinished work no longer ends as a plain success just because the model produced no tool call. The hook appends a bounded nudge re-ask (maxAttempts, optional deadline mirroring the T04 retry vocabulary) and terminates with an explicit terminal ActionDecision under argi.actionDecision.final: NEED_INPUT by default on exhaustion, STOP for identical repeated decisions and for an optional unknown-tool bound. New reason codes: UNFINISHED_REQUIRED_TASKS, NO_PROGRESS, REPEATED_DECISION, UNKNOWN_TOOL. All counters stay run-scoped in the run config context, a stale terminal decision is cleared on a fresh run, and default routing is unchanged when the hook is absent. Model-agnostic tests cover nudge, exhaustion, deadline, both exit behaviors, stream/non-stream consistency and routing regressions. Slice 2 for #139. --- .../graph/agent/action/ActionReasonCodes.java | 29 +- .../TaskCompletionExceededException.java | 47 ++ .../taskcompletion/TaskCompletionHook.java | 512 ++++++++++++++++++ .../TaskCompletionPredicates.java | 107 ++++ ...39TaskCompletionRoutingRegressionTest.java | 231 ++++++++ .../agent/action/ActionReasonCodesTest.java | 56 ++ .../TaskCompletionHookDeadlineTest.java | 120 ++++ .../TaskCompletionHookOfflineTest.java | 398 ++++++++++++++ ...etionHookStreamConsistencyOfflineTest.java | 163 ++++++ .../TaskCompletionHookTest.java | 68 +++ 10 files changed, 1728 insertions(+), 3 deletions(-) create mode 100644 argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionExceededException.java create mode 100644 argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHook.java create mode 100644 argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionPredicates.java create mode 100644 argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/Issue139TaskCompletionRoutingRegressionTest.java create mode 100644 argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/action/ActionReasonCodesTest.java create mode 100644 argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHookDeadlineTest.java create mode 100644 argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookOfflineTest.java create mode 100644 argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookStreamConsistencyOfflineTest.java create mode 100644 argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookTest.java diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/action/ActionReasonCodes.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/action/ActionReasonCodes.java index 1dec22d42..f3da8db0b 100644 --- a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/action/ActionReasonCodes.java +++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/action/ActionReasonCodes.java @@ -19,9 +19,8 @@ /** * Short, stable reason codes carried by {@link ActionDecision}. * - *

Reason codes are operational labels, never private model reasoning. Additional - * codes for routing and bounded-exit policies arrive with later slices of the action - * decision work.

+ *

Reason codes are operational labels, never private model reasoning. Codes cover tool + * selection outcomes and the bounded-exit policies of the task completion work.

*/ public final class ActionReasonCodes { @@ -41,6 +40,30 @@ public final class ActionReasonCodes { */ public static final String TOOL_SELECTION_MODEL_FAILED = "TOOL_SELECTION_MODEL_FAILED"; + /** + * Required tasks are still unfinished and the bounded re-ask budget is exhausted, so + * the run terminates with an explicit state instead of a plain success. + */ + public static final String UNFINISHED_REQUIRED_TASKS = "UNFINISHED_REQUIRED_TASKS"; + + /** + * The bounded re-ask budget is exhausted and every no-tool answer in the sequence was + * identical: the run is not making progress. + */ + public static final String NO_PROGRESS = "NO_PROGRESS"; + + /** + * The same decision fingerprint (identical tool calls or identical final answer) + * repeated for too many consecutive model turns. + */ + public static final String REPEATED_DECISION = "REPEATED_DECISION"; + + /** + * More tool calls to unresolvable tool names occurred in this run than the configured + * bound allows. + */ + public static final String UNKNOWN_TOOL = "UNKNOWN_TOOL"; + private ActionReasonCodes() { } diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionExceededException.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionExceededException.java new file mode 100644 index 000000000..0f4123476 --- /dev/null +++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionExceededException.java @@ -0,0 +1,47 @@ +/* + * Copyright 2025-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.graph.agent.hook.taskcompletion; + +import io.github.agentic.ai.graph.agent.action.ActionDecision; + +/** + * Thrown by {@link TaskCompletionHook} in {@link TaskCompletionHook.ExitBehavior#ERROR} + * mode when a bounded exit is reached. The terminal {@link ActionDecision} is available + * via {@link #getDecision()}. + */ +public class TaskCompletionExceededException extends RuntimeException { + + private final ActionDecision decision; + + /** + * Creates the exception carrying the terminal decision. + * @param decision the terminal decision, must not be {@code null} + */ + public TaskCompletionExceededException(ActionDecision decision) { + super(decision.reasonCode() + (decision.detail() == null ? "" : ": " + decision.detail())); + this.decision = decision; + } + + /** + * Returns the terminal decision that triggered this bounded exit. + * @return the terminal decision + */ + public ActionDecision getDecision() { + return decision; + } + +} diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHook.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHook.java new file mode 100644 index 000000000..d566e9944 --- /dev/null +++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHook.java @@ -0,0 +1,512 @@ +/* + * Copyright 2025-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.graph.agent.hook.taskcompletion; + +import io.github.agentic.ai.api.chat.ChatMessage; +import io.github.agentic.ai.api.chat.ChatRole; +import io.github.agentic.ai.graph.KeyStrategy; +import io.github.agentic.ai.graph.OverAllState; +import io.github.agentic.ai.graph.RunnableConfig; +import io.github.agentic.ai.graph.agent.action.ActionDecision; +import io.github.agentic.ai.graph.agent.action.ActionReasonCodes; +import io.github.agentic.ai.graph.agent.action.ActionType; +import io.github.agentic.ai.graph.agent.hook.HookPosition; +import io.github.agentic.ai.graph.agent.hook.HookPositions; +import io.github.agentic.ai.graph.agent.hook.JumpTo; +import io.github.agentic.ai.graph.agent.hook.ModelHook; +import io.github.agentic.ai.graph.agent.interceptor.ToolCallResponse; + +import java.time.Duration; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import java.util.function.LongSupplier; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +/** + * Opt-in hook that refuses to let a run with unfinished required tasks end as a plain + * success just because the model produced no tool call (T36). + * + *

Register the hook through the existing builder, e.g. + * {@code ReactAgent.builder().hooks(TaskCompletionHook.builder().build())}. When the + * completion predicate reports unfinished work and the model answered without tool + * calls, the hook appends one nudge user message and jumps back to the model, at most + * {@link Builder#maxAttempts(int)} times and never beyond {@link Builder#deadline(Duration)}. + * Once that budget is exhausted the run terminates with an explicit terminal decision + * under {@link #TERMINAL_DECISION_STATE_KEY} (default {@code NEED_INPUT}) instead of an + * implicit success. Identical consecutive decisions and unresolvable tool calls stop the + * run with {@code STOP} reason codes. All counters are run-scoped and never enter the + * checkpointed state.

+ */ +@HookPositions({ HookPosition.BEFORE_MODEL, HookPosition.AFTER_MODEL }) +public class TaskCompletionHook extends ModelHook { + + /** + * State key holding the terminal {@link ActionDecision} of the most recent run. The + * key only exists when this hook is registered and terminates a run. + */ + public static final String TERMINAL_DECISION_STATE_KEY = "argi.actionDecision.final"; + + private static final String MESSAGES_STATE_KEY = "messages"; + + private static final String JUMP_TO_STATE_KEY = "jump_to"; + + private static final String NUDGE_COUNT_CONTEXT_KEY = "__task_completion_nudge_count__"; + + private static final String LAST_FINGERPRINT_CONTEXT_KEY = "__task_completion_last_fingerprint__"; + + private static final String REPEAT_COUNT_CONTEXT_KEY = "__task_completion_repeat_count__"; + + private static final String DEADLINE_START_CONTEXT_KEY = "__task_completion_deadline_start_nanos__"; + + private static final String MESSAGE_BASELINE_CONTEXT_KEY = "__task_completion_message_baseline__"; + + private static final String PENDING_ERROR_CONTEXT_KEY = "__task_completion_pending_error__"; + + private static final String TERMINAL_WRITTEN_CONTEXT_KEY = "__task_completion_terminal_written__"; + + private final Predicate completionPredicate; + + private final int maxAttempts; + + private final Duration deadline; + + private final int maxConsecutiveRepeats; + + private final Integer maxUnknownToolCalls; + + private final TerminalAction onUnfinishedExhaustion; + + private final ExitBehavior exitBehavior; + + private final Function, String> nudgeText; + + private final LongSupplier nanoTime; + + TaskCompletionHook(Builder builder) { + this.completionPredicate = builder.completionPredicate; + this.maxAttempts = builder.maxAttempts; + this.deadline = builder.deadline; + this.maxConsecutiveRepeats = builder.maxConsecutiveRepeats; + this.maxUnknownToolCalls = builder.maxUnknownToolCalls; + this.onUnfinishedExhaustion = builder.onUnfinishedExhaustion; + this.exitBehavior = builder.exitBehavior; + this.nudgeText = builder.nudgeText; + this.nanoTime = builder.nanoTime; + } + + /** + * Creates a new builder with defaults: todos-based completion predicate, two nudge + * attempts, no deadline, repeat bound three, unknown-tool bound disabled, terminal + * action {@link TerminalAction#NEED_INPUT} and {@link ExitBehavior#END}. + * @return the builder + */ + public static Builder builder() { + return new Builder(); + } + + @Override + public String getName() { + return "TaskCompletion"; + } + + @Override + public List canJumpTo() { + return List.of(JumpTo.model, JumpTo.end); + } + + @Override + public Map getKeyStrategys() { + return Map.of(TERMINAL_DECISION_STATE_KEY, KeyStrategy.REPLACE); + } + + /** + * Cheap pre-call guards: surfaces a pending terminal error in + * {@link ExitBehavior#ERROR} mode and enforces the optional unknown-tool bound. + */ + @Override + public CompletableFuture> beforeModel(OverAllState state, RunnableConfig config) { + Object pending = config.context().remove(PENDING_ERROR_CONTEXT_KEY); + if (pending instanceof TaskCompletionExceededException error) { + throw error; + } + if (isTerminalWritten(config)) { + return CompletableFuture.completedFuture(Map.of()); + } + List messages = messages(state); + Object baselineValue = config.context().get(MESSAGE_BASELINE_CONTEXT_KEY); + if (!(baselineValue instanceof Integer baseline)) { + config.context().put(MESSAGE_BASELINE_CONTEXT_KEY, messages.size()); + // A fresh run clears any terminal decision left over from the previous run on + // the same thread, so the key always reflects the latest run. + return CompletableFuture + .completedFuture(Map.of(TERMINAL_DECISION_STATE_KEY, OverAllState.MARK_FOR_REMOVAL)); + } + if (maxUnknownToolCalls == null) { + return CompletableFuture.completedFuture(Map.of()); + } + int unresolved = countUnresolvedToolMessages(messages, baseline); + if (unresolved > maxUnknownToolCalls) { + ActionDecision decision = ActionDecision.of(ActionType.STOP, ActionReasonCodes.UNKNOWN_TOOL, + unresolved + " tool calls to unknown tools exceeded the bound of " + maxUnknownToolCalls); + return CompletableFuture + .completedFuture(terminalExit(config, decision, TaskCompletionPredicates.unfinishedTodoLines(state))); + } + return CompletableFuture.completedFuture(Map.of()); + } + + /** + * Routing-time guard: refuses a plain finish while required tasks are unfinished, + * nudges the model back (bounded), and stops repeated or unproductive loops. + */ + @Override + public CompletableFuture> afterModel(OverAllState state, RunnableConfig config) { + if (isTerminalWritten(config)) { + return CompletableFuture.completedFuture(Map.of()); + } + List messages = messages(state); + if (messages.isEmpty()) { + return CompletableFuture.completedFuture(Map.of()); + } + ChatMessage last = messages.get(messages.size() - 1); + if (last.role() != ChatRole.ASSISTANT) { + return CompletableFuture.completedFuture(Map.of()); + } + + int repeatCount = trackDecisionFingerprint(last, config); + List unfinishedLines = TaskCompletionPredicates.unfinishedTodoLines(state); + boolean workUnfinished = !completionPredicate.test(state); + int nudges = intFrom(config.context().get(NUDGE_COUNT_CONTEXT_KEY)); + boolean budgetExhausted = workUnfinished && !last.hasToolCalls() + && (nudges >= maxAttempts || isDeadlineExceeded(config)); + + if (budgetExhausted) { + // Classified first so an exhausted nudge loop with identical answers reports + // NO_PROGRESS rather than the generic repeat guard. + ActionType action = onUnfinishedExhaustion == TerminalAction.NEED_INPUT ? ActionType.NEED_INPUT + : ActionType.STOP; + String reasonCode = repeatCount >= maxAttempts + 1 ? ActionReasonCodes.NO_PROGRESS + : ActionReasonCodes.UNFINISHED_REQUIRED_TASKS; + ActionDecision decision = ActionDecision.of(action, reasonCode, + "Unfinished work remains after " + nudges + " nudges" + + (deadline != null ? " or its deadline" : "")); + return CompletableFuture.completedFuture(terminalExit(config, decision, unfinishedLines)); + } + + if (repeatCount >= maxConsecutiveRepeats) { + ActionDecision decision = ActionDecision.of(ActionType.STOP, ActionReasonCodes.REPEATED_DECISION, + "The same decision repeated " + repeatCount + " consecutive times"); + return CompletableFuture.completedFuture(terminalExit(config, decision, unfinishedLines)); + } + + if (last.hasToolCalls()) { + // A productive turn resets the nudge budget. + config.context().put(NUDGE_COUNT_CONTEXT_KEY, 0); + return CompletableFuture.completedFuture(Map.of()); + } + + if (!workUnfinished) { + return CompletableFuture.completedFuture(Map.of()); + } + + if (nudges < maxAttempts && !isDeadlineExceeded(config)) { + if (deadline != null) { + config.context().putIfAbsent(DEADLINE_START_CONTEXT_KEY, nanoTime.getAsLong()); + } + config.context().put(NUDGE_COUNT_CONTEXT_KEY, nudges + 1); + Map updates = new HashMap<>(); + updates.put(MESSAGES_STATE_KEY, List.of(ChatMessage.user(nudgeText.apply(unfinishedLines)))); + updates.put(JUMP_TO_STATE_KEY, JumpTo.model); + return CompletableFuture.completedFuture(updates); + } + + // Unreachable in practice: an exhausted budget is handled before the nudge branch. + return CompletableFuture.completedFuture(Map.of()); + } + + private Map terminalExit(RunnableConfig config, ActionDecision decision, + List unfinishedLines) { + config.context().put(TERMINAL_WRITTEN_CONTEXT_KEY, Boolean.TRUE); + Map updates = new HashMap<>(); + updates.put(MESSAGES_STATE_KEY, List.of(ChatMessage.assistant(terminalMessage(decision, unfinishedLines)))); + updates.put(TERMINAL_DECISION_STATE_KEY, decision); + if (exitBehavior == ExitBehavior.ERROR) { + // Route toward the next beforeModel so the terminal state is checkpointed + // before the error surfaces there. + config.context().put(PENDING_ERROR_CONTEXT_KEY, new TaskCompletionExceededException(decision)); + updates.put(JUMP_TO_STATE_KEY, JumpTo.model); + } + else { + updates.put(JUMP_TO_STATE_KEY, JumpTo.end); + } + return updates; + } + + private String terminalMessage(ActionDecision decision, List unfinishedLines) { + StringBuilder text = new StringBuilder("Run terminated with ").append(decision.type()) + .append(" (") + .append(decision.reasonCode()) + .append(")."); + if (!unfinishedLines.isEmpty()) { + text.append(" Unfinished tasks:"); + for (String line : unfinishedLines) { + text.append("\n- ").append(line); + } + } + return text.toString(); + } + + private int trackDecisionFingerprint(ChatMessage message, RunnableConfig config) { + String fingerprint = decisionFingerprint(message); + boolean repeated = fingerprint.equals(config.context().get(LAST_FINGERPRINT_CONTEXT_KEY)); + int repeatCount = repeated ? intFrom(config.context().get(REPEAT_COUNT_CONTEXT_KEY)) + 1 : 1; + config.context().put(LAST_FINGERPRINT_CONTEXT_KEY, fingerprint); + config.context().put(REPEAT_COUNT_CONTEXT_KEY, repeatCount); + return repeatCount; + } + + private String decisionFingerprint(ChatMessage message) { + if (!message.hasToolCalls()) { + return "final:" + message.text(); + } + return "tools:" + message.toolCalls() + .stream() + .map(call -> call.name() + ":" + call.arguments()) + .sorted() + .collect(Collectors.joining(",")); + } + + private boolean isDeadlineExceeded(RunnableConfig config) { + if (deadline == null) { + return false; + } + if (!(config.context().get(DEADLINE_START_CONTEXT_KEY) instanceof Long start)) { + return false; + } + return nanoTime.getAsLong() - start > deadline.toNanos(); + } + + private boolean isTerminalWritten(RunnableConfig config) { + return Boolean.TRUE.equals(config.context().get(TERMINAL_WRITTEN_CONTEXT_KEY)); + } + + private int countUnresolvedToolMessages(List messages, int baseline) { + int unresolved = 0; + for (int i = baseline; i < messages.size(); i++) { + ChatMessage message = messages.get(i); + if (message.role() == ChatRole.TOOL && message.metadata() != null + && ToolCallResponse.FAILURE_KIND_UNRESOLVED + .equals(message.metadata().get(ToolCallResponse.FAILURE_KIND_METADATA_KEY))) { + unresolved++; + } + } + return unresolved; + } + + @SuppressWarnings("unchecked") + private static List messages(OverAllState state) { + return (List) state.value(MESSAGES_STATE_KEY).orElse(List.of()); + } + + private static int intFrom(Object value) { + return value instanceof Number number ? number.intValue() : 0; + } + + /** + * How a bounded exit terminates the run. + */ + public enum ExitBehavior { + + /** Append a terminal assistant message, record the terminal decision, jump to end. */ + END, + + /** + * Record the terminal state first, then throw + * {@link TaskCompletionExceededException} before the next model call so the + * terminal state is checkpointed before the error surfaces. + */ + ERROR + + } + + /** + * Terminal action recorded when the unfinished-task re-ask budget is exhausted. + */ + public enum TerminalAction { + + /** The run cannot proceed without additional external input. */ + NEED_INPUT, + + /** Stop the run without widening the execution scope. */ + STOP + + } + + /** + * Builder for {@link TaskCompletionHook}. + */ + public static class Builder { + + private Predicate completionPredicate = TaskCompletionPredicates.todosComplete(); + + private int maxAttempts = 2; + + private Duration deadline; + + private int maxConsecutiveRepeats = 3; + + private Integer maxUnknownToolCalls; + + private TerminalAction onUnfinishedExhaustion = TerminalAction.NEED_INPUT; + + private ExitBehavior exitBehavior = ExitBehavior.END; + + private Function, String> nudgeText = TaskCompletionHook::defaultNudgeText; + + private LongSupplier nanoTime = System::nanoTime; + + /** + * Sets the predicate deciding whether the run's required work is complete. + * @param predicate returns {@code true} when the work is complete + * @return this builder + */ + public Builder completionPredicate(Predicate predicate) { + this.completionPredicate = Objects.requireNonNull(predicate, "completionPredicate must not be null"); + return this; + } + + /** + * Sets how many nudge re-asks are allowed before the run terminates with an + * explicit terminal decision. + * @param maxAttempts the maximum number of nudges, at least 1 + * @return this builder + */ + public Builder maxAttempts(int maxAttempts) { + if (maxAttempts < 1) { + throw new IllegalArgumentException("maxAttempts must be at least 1"); + } + this.maxAttempts = maxAttempts; + return this; + } + + /** + * Sets an optional wall-clock budget for the whole nudge loop, measured from the + * first nudge. No deadline is applied by default. + * @param deadline a positive duration, or {@code null} to disable + * @return this builder + */ + public Builder deadline(Duration deadline) { + if (deadline != null && (deadline.isZero() || deadline.isNegative())) { + throw new IllegalArgumentException("deadline must be positive when set"); + } + this.deadline = deadline; + return this; + } + + /** + * Sets how many consecutive identical decisions (identical tool calls or an + * identical final answer) are tolerated before the run stops. + * @param maxConsecutiveRepeats the tolerated repeat count, at least 2 + * @return this builder + */ + public Builder maxConsecutiveRepeats(int maxConsecutiveRepeats) { + if (maxConsecutiveRepeats < 2) { + throw new IllegalArgumentException("maxConsecutiveRepeats must be at least 2"); + } + this.maxConsecutiveRepeats = maxConsecutiveRepeats; + return this; + } + + /** + * Sets an optional bound on tool calls to unresolvable tool names in this run. + * Disabled by default. + * @param maxUnknownToolCalls the tolerated unresolved count, at least 1, or + * {@code null} to disable + * @return this builder + */ + public Builder maxUnknownToolCalls(Integer maxUnknownToolCalls) { + if (maxUnknownToolCalls != null && maxUnknownToolCalls < 1) { + throw new IllegalArgumentException("maxUnknownToolCalls must be at least 1 when set"); + } + this.maxUnknownToolCalls = maxUnknownToolCalls; + return this; + } + + /** + * Sets the terminal action recorded when the unfinished-task budget is exhausted. + * @param action the terminal action + * @return this builder + */ + public Builder onUnfinishedExhaustion(TerminalAction action) { + this.onUnfinishedExhaustion = Objects.requireNonNull(action, "action must not be null"); + return this; + } + + /** + * Sets how a bounded exit terminates the run. + * @param exitBehavior the exit behavior + * @return this builder + */ + public Builder exitBehavior(ExitBehavior exitBehavior) { + this.exitBehavior = Objects.requireNonNull(exitBehavior, "exitBehavior must not be null"); + return this; + } + + /** + * Sets the renderer for the nudge user message. It receives one operational line + * per unfinished todo (empty for custom predicates without todos). + * @param nudgeText renders the nudge message text + * @return this builder + */ + public Builder nudgeText(Function, String> nudgeText) { + this.nudgeText = Objects.requireNonNull(nudgeText, "nudgeText must not be null"); + return this; + } + + Builder nanoTime(LongSupplier nanoTime) { + this.nanoTime = Objects.requireNonNull(nanoTime, "nanoTime must not be null"); + return this; + } + + /** + * Builds the hook. + * @return the hook + */ + public TaskCompletionHook build() { + return new TaskCompletionHook(this); + } + + } + + static String defaultNudgeText(List unfinishedLines) { + StringBuilder text = new StringBuilder("Required tasks are not complete."); + if (!unfinishedLines.isEmpty()) { + text.append(" Unfinished:"); + for (String line : unfinishedLines) { + text.append("\n- ").append(line); + } + } + text.append( + "\nContinue with the available tools, mark finished tasks completed, or state exactly what input you need."); + return text.toString(); + } + +} diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionPredicates.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionPredicates.java new file mode 100644 index 000000000..4e70b7abf --- /dev/null +++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionPredicates.java @@ -0,0 +1,107 @@ +/* + * Copyright 2025-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.graph.agent.hook.taskcompletion; + +import io.github.agentic.ai.graph.OverAllState; +import io.github.agentic.ai.graph.agent.interceptor.todolist.TodoListInterceptor; +import io.github.agentic.ai.graph.agent.interceptor.todolist.TodoListInterceptor.Todo; +import io.github.agentic.ai.graph.agent.interceptor.todolist.TodoListInterceptor.TodoStatus; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; + +/** + * Built-in completion predicates for {@link TaskCompletionHook}. + * + *

The default predicate treats the run's required work as unfinished while any todo + * written by {@link TodoListInterceptor}'s {@code write_todos} tool is still + * {@code pending} or {@code in_progress}. Missing, empty or unrecognized todo shapes are + * treated as complete so the guard never blocks runs that do not use todos.

+ */ +public final class TaskCompletionPredicates { + + private static final String TODOS_STATE_KEY = "todos"; + + private static final String STATUS_PENDING = "pending"; + + private static final String STATUS_IN_PROGRESS = "in_progress"; + + private TaskCompletionPredicates() { + } + + /** + * Returns the default completion predicate: work is complete when the {@code todos} + * state key is absent, empty, or holds only completed todos. + * @return predicate returning {@code true} when no unfinished todo remains + */ + public static Predicate todosComplete() { + return state -> unfinishedTodoLines(state).isEmpty(); + } + + /** + * Renders one operational line per unfinished todo, e.g. + * {@code pending: run the tests}. Elements the reader does not recognize are + * skipped. + * @param state the current state holding the {@code todos} key + * @return lines describing unfinished todos, empty when there are none + */ + static List unfinishedTodoLines(OverAllState state) { + List lines = new ArrayList<>(); + Object todos = state.value(TODOS_STATE_KEY).orElse(null); + if (!(todos instanceof List entries) || entries.isEmpty()) { + return lines; + } + for (Object entry : entries) { + String status = statusOf(entry); + String content = contentOf(entry); + if (isUnfinishedStatus(status) && content != null) { + lines.add(status + ": " + content); + } + } + return lines; + } + + private static boolean isUnfinishedStatus(String status) { + return STATUS_PENDING.equals(status) || STATUS_IN_PROGRESS.equals(status); + } + + private static String statusOf(Object entry) { + if (entry instanceof Todo todo && todo.getStatus() != null) { + return todo.getStatus().getValue(); + } + if (entry instanceof Map map && map.get("status") instanceof String status) { + return status; + } + if (entry instanceof TodoStatus todoStatus) { + return todoStatus.getValue(); + } + return null; + } + + private static String contentOf(Object entry) { + if (entry instanceof Todo todo && todo.getContent() != null && !todo.getContent().isBlank()) { + return todo.getContent(); + } + if (entry instanceof Map map && map.get("content") instanceof String content && !content.isBlank()) { + return content; + } + return null; + } + +} diff --git a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/Issue139TaskCompletionRoutingRegressionTest.java b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/Issue139TaskCompletionRoutingRegressionTest.java new file mode 100644 index 000000000..44c991ce2 --- /dev/null +++ b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/Issue139TaskCompletionRoutingRegressionTest.java @@ -0,0 +1,231 @@ +/* + * Copyright 2025-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.graph.agent; + +import io.github.agentic.ai.adapter.springai.SpringAiToolAdapter; +import io.github.agentic.ai.api.chat.ChatClient; +import io.github.agentic.ai.api.chat.ChatClientCapabilities; +import io.github.agentic.ai.api.chat.ChatEvent; +import io.github.agentic.ai.api.chat.ChatMessage; +import io.github.agentic.ai.api.chat.ChatRequest; +import io.github.agentic.ai.api.chat.ChatResponse; +import io.github.agentic.ai.api.chat.FinishReason; +import io.github.agentic.ai.api.chat.ToolCall; +import io.github.agentic.ai.api.chat.Usage; +import io.github.agentic.ai.graph.OverAllState; +import io.github.agentic.ai.graph.agent.hook.taskcompletion.TaskCompletionHook; +import io.github.agentic.ai.graph.agent.interceptor.toolselection.ToolSelectionFailurePolicy; +import io.github.agentic.ai.graph.agent.interceptor.toolselection.ToolSelectionInterceptor; +import io.github.agentic.ai.graph.agent.interceptor.todolist.TodoListInterceptor.Todo; +import io.github.agentic.ai.graph.agent.interceptor.todolist.TodoListInterceptor.TodoStatus; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.annotation.Tool; + +import java.util.ArrayDeque; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Regression pins for issue #139: the task completion guard is strictly opt-in (default + * routing is unchanged even with unfinished todos), the strict tool-selection fallback + * still narrows tools when both components are registered, and a legal empty selection + * still proceeds to a plain final answer. + */ +class Issue139TaskCompletionRoutingRegressionTest { + + @Test + void defaultRoutingUnchangedWithoutHookEvenWithUnfinishedTodos() throws Exception { + ScriptedClient client = new ScriptedClient(text("partial answer")); + + Optional result = ReactAgent.builder() + .name("hookless-agent") + .chatClient(client) + .build() + .invoke(Map.of("messages", List.of(ChatMessage.user("do the work")), "todos", + List.of(new Todo("run tests", TodoStatus.PENDING)))); + + assertTrue(result.isPresent()); + assertEquals(1, client.requests.size()); + assertFalse(result.get().value(TaskCompletionHook.TERMINAL_DECISION_STATE_KEY).isPresent()); + List messages = messagesOf(result.get()); + assertEquals("partial answer", messages.get(messages.size() - 1).text()); + } + + @Test + void strictAlwaysIncludeFallbackStillHonoredWithCompletionHookRegistered() throws Exception { + ScriptedClient client = new ScriptedClient(toolCall("call-1", "lookup", "{}"), text("done")); + DualTools tools = new DualTools(); + ToolSelectionInterceptor selection = ToolSelectionInterceptor.builder() + .chatClient(new SelectionModel("not json")) + .maxTools(1) + .alwaysInclude("lookup") + .failurePolicy(ToolSelectionFailurePolicy.FAIL_CLOSED_ALWAYS_INCLUDE) + .build(); + + Optional result = agent(client, selection, tools) + .invoke(Map.of("messages", List.of(ChatMessage.user("look up")))); + + assertTrue(result.isPresent()); + // The strict failure fallback narrowed the tool set to the mandatory tool instead + // of widening it to every available tool. + assertEquals(List.of("lookup"), + client.requests.get(0).tools().stream().map(tool -> tool.name()).toList()); + assertEquals(1, tools.lookupExecutions.get()); + assertFalse(result.get().value(TaskCompletionHook.TERMINAL_DECISION_STATE_KEY).isPresent()); + } + + @Test + void emptyToolSelectionProceedsToFinalAnswerWithHookPresent() throws Exception { + ScriptedClient client = new ScriptedClient(text("plain answer")); + ToolSelectionInterceptor selection = ToolSelectionInterceptor.builder() + .chatClient(new SelectionModel("{\"tools\":[]}")) + .maxTools(1) + .build(); + + Optional result = agent(client, selection, new DualTools()) + .invoke(Map.of("messages", List.of(ChatMessage.user("just answer")))); + + assertTrue(result.isPresent()); + assertTrue(client.requests.get(0).tools().isEmpty()); + assertEquals(1, client.requests.size()); + assertFalse(result.get().value(TaskCompletionHook.TERMINAL_DECISION_STATE_KEY).isPresent()); + } + + // ------------------- harness ------------------- + + private static ReactAgent agent(ChatClient client, ToolSelectionInterceptor selection, DualTools tools) { + return ReactAgent.builder() + .name("regression-agent") + .chatClient(client) + .toolRegistry(SpringAiToolAdapter.fromCallbacks(ToolCallbacks.from(tools))) + .interceptors(selection) + .hooks(TaskCompletionHook.builder().build()) + .build(); + } + + @SuppressWarnings("unchecked") + private static List messagesOf(OverAllState state) { + return (List) state.value("messages").orElse(List.of()); + } + + private static ChatResponse text(String content) { + return response(content, List.of()); + } + + private static ChatResponse toolCall(String id, String name, String arguments) { + return response("", List.of(new ToolCall(id, name, arguments))); + } + + private static ChatResponse response(String content, List tools) { + return new ChatResponse("response-id", "test-model", List.of(ChatMessage.assistant(content, tools)), tools, + tools.isEmpty() ? FinishReason.STOP : FinishReason.TOOL_CALLS, new Usage(2L, 3L, 5L), Map.of()); + } + + static final class DualTools { + + final AtomicInteger lookupExecutions = new AtomicInteger(); + + @Tool(name = "lookup", description = "Looks up a value") + public String lookup(String query) { + lookupExecutions.incrementAndGet(); + return "result for " + query; + } + + @Tool(name = "lookup2", description = "Looks up another value") + public String lookup2(String query) { + return "result2 for " + query; + } + + } + + /** Selection model stub answering with a fixed JSON body, as in the slice-1 tests. */ + private static final class SelectionModel implements ChatClient { + + private final String json; + + SelectionModel(String json) { + this.json = json; + } + + @Override + public CompletionStage call(ChatRequest request) { + return CompletableFuture.completedFuture( + new ChatResponse(null, null, List.of(ChatMessage.assistant(json)), List.of(), null, null, Map.of())); + } + + @Override + public Flow.Publisher stream(ChatRequest request) { + throw new AssertionError("Tool selection must use a single non-streaming turn"); + } + + @Override + public ChatClientCapabilities capabilities() { + return ChatClientCapabilities.basic(); + } + + } + + private static final class ScriptedClient implements ChatClient { + + private final Queue turns; + + final List requests = new CopyOnWriteArrayList<>(); + + ScriptedClient(ChatResponse... responses) { + this.turns = new ArrayDeque<>(List.of(responses)); + } + + @Override + public CompletionStage call(ChatRequest request) { + requests.add(request); + return CompletableFuture.completedFuture(requireNextTurn()); + } + + @Override + public Flow.Publisher stream(ChatRequest request) { + throw new AssertionError("Regression tests drive the agent through the blocking API"); + } + + @Override + public ChatClientCapabilities capabilities() { + return new ChatClientCapabilities(false, true, false, false, true, true); + } + + private ChatResponse requireNextTurn() { + ChatResponse next = turns.poll(); + if (next == null) { + throw new AssertionError("No scripted model turn left"); + } + return next; + } + + } + +} diff --git a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/action/ActionReasonCodesTest.java b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/action/ActionReasonCodesTest.java new file mode 100644 index 000000000..fa2f8a98e --- /dev/null +++ b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/action/ActionReasonCodesTest.java @@ -0,0 +1,56 @@ +/* + * Copyright 2025-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.graph.agent.action; + +import java.lang.reflect.Field; +import java.util.Set; +import java.util.TreeSet; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Model-independent contract test pinning the stable reason-code labels of the action + * decision vocabulary (T36 slices 1 and 2). + */ +class ActionReasonCodesTest { + + @Test + void sliceOneAndSliceTwoCodesAreStableLabels() { + assertEquals("TOOL_SELECTION_EMPTY", ActionReasonCodes.TOOL_SELECTION_EMPTY); + assertEquals("TOOL_SELECTION_MALFORMED", ActionReasonCodes.TOOL_SELECTION_MALFORMED); + assertEquals("TOOL_SELECTION_MODEL_FAILED", ActionReasonCodes.TOOL_SELECTION_MODEL_FAILED); + assertEquals("UNFINISHED_REQUIRED_TASKS", ActionReasonCodes.UNFINISHED_REQUIRED_TASKS); + assertEquals("NO_PROGRESS", ActionReasonCodes.NO_PROGRESS); + assertEquals("REPEATED_DECISION", ActionReasonCodes.REPEATED_DECISION); + assertEquals("UNKNOWN_TOOL", ActionReasonCodes.UNKNOWN_TOOL); + } + + @Test + void reasonCodeValuesAreUnique() throws Exception { + Set values = new TreeSet<>(); + for (Field field : ActionReasonCodes.class.getDeclaredFields()) { + if (field.getType() == String.class) { + field.setAccessible(true); + values.add((String) field.get(null)); + } + } + assertEquals(7, values.size()); + } + +} diff --git a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHookDeadlineTest.java b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHookDeadlineTest.java new file mode 100644 index 000000000..eeed8feba --- /dev/null +++ b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHookDeadlineTest.java @@ -0,0 +1,120 @@ +/* + * Copyright 2025-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.graph.agent.hook.taskcompletion; + +import io.github.agentic.ai.api.chat.ChatClient; +import io.github.agentic.ai.api.chat.ChatClientCapabilities; +import io.github.agentic.ai.api.chat.ChatMessage; +import io.github.agentic.ai.api.chat.ChatRequest; +import io.github.agentic.ai.api.chat.ChatResponse; +import io.github.agentic.ai.api.chat.FinishReason; +import io.github.agentic.ai.api.chat.Usage; +import io.github.agentic.ai.graph.agent.ReactAgent; +import io.github.agentic.ai.graph.agent.action.ActionDecision; +import io.github.agentic.ai.graph.agent.interceptor.todolist.TodoListInterceptor.Todo; +import io.github.agentic.ai.graph.agent.interceptor.todolist.TodoListInterceptor.TodoStatus; + +import org.junit.jupiter.api.Test; + +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.atomic.AtomicLong; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Deadline behavior of {@link TaskCompletionHook} driven through the package-private + * {@code nanoTime} test seam. + */ +class TaskCompletionHookDeadlineTest { + + @Test + void expiredDeadlineStopsTheNudgeLoopWithoutFurtherModelCalls() throws Exception { + // First read anchors the deadline at the first nudge; the second read (the next + // nudge check) is already past the one-second budget. + long[] clockReads = { 0L, Duration.ofSeconds(5).toNanos() }; + AtomicLong reads = new AtomicLong(); + ScriptedClient client = new ScriptedClient(response("answer one"), response("answer two")); + TaskCompletionHook hook = TaskCompletionHook.builder() + .maxAttempts(3) + .deadline(Duration.ofSeconds(1)) + .nanoTime(() -> clockReads[(int) Math.min(reads.getAndIncrement(), clockReads.length - 1)]) + .build(); + ReactAgent agent = ReactAgent.builder() + .name("deadline-agent") + .chatClient(client) + .hooks(hook) + .build(); + + var result = agent.invoke(Map.of("messages", List.of(ChatMessage.user("do the work")), "todos", + List.of(new Todo("run tests", TodoStatus.PENDING)))); + + assertTrue(result.isPresent()); + assertEquals(2, client.requests.size()); + Object decision = result.get().value(TaskCompletionHook.TERMINAL_DECISION_STATE_KEY).orElse(null); + ActionDecision typed = assertInstanceOf(ActionDecision.class, decision); + assertEquals("NEED_INPUT", typed.type().name()); + assertEquals("UNFINISHED_REQUIRED_TASKS", typed.reasonCode()); + } + + private static ChatResponse response(String content) { + return new ChatResponse("response-id", "test-model", List.of(ChatMessage.assistant(content)), List.of(), + FinishReason.STOP, new Usage(2L, 3L, 5L), Map.of()); + } + + private static final class ScriptedClient implements ChatClient { + + private final Queue turns; + + final List requests = new CopyOnWriteArrayList<>(); + + ScriptedClient(ChatResponse... responses) { + this.turns = new ArrayDeque<>(List.of(responses)); + } + + @Override + public CompletionStage call(ChatRequest request) { + requests.add(request); + ChatResponse next = turns.poll(); + if (next == null) { + throw new AssertionError("No scripted model turn left"); + } + return CompletableFuture.completedFuture(next); + } + + @Override + public java.util.concurrent.Flow.Publisher stream( + ChatRequest request) { + throw new UnsupportedOperationException("Stream not configured"); + } + + @Override + public ChatClientCapabilities capabilities() { + return new ChatClientCapabilities(false, true, false, false, true, true); + } + + } + +} diff --git a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookOfflineTest.java b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookOfflineTest.java new file mode 100644 index 000000000..8385c5f7a --- /dev/null +++ b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookOfflineTest.java @@ -0,0 +1,398 @@ +/* + * Copyright 2025-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.graph.agent.hooks.taskcompletion; + +import io.github.agentic.ai.api.chat.ChatClient; +import io.github.agentic.ai.api.chat.ChatClientCapabilities; +import io.github.agentic.ai.api.chat.ChatEvent; +import io.github.agentic.ai.api.chat.ChatMessage; +import io.github.agentic.ai.api.chat.ChatRequest; +import io.github.agentic.ai.api.chat.ChatResponse; +import io.github.agentic.ai.api.chat.ChatRole; +import io.github.agentic.ai.api.chat.FinishReason; +import io.github.agentic.ai.api.chat.ToolCall; +import io.github.agentic.ai.api.chat.Usage; +import io.github.agentic.ai.adapter.springai.SpringAiToolAdapter; +import io.github.agentic.ai.graph.OverAllState; +import io.github.agentic.ai.graph.RunnableConfig; +import io.github.agentic.ai.graph.agent.ReactAgent; +import io.github.agentic.ai.graph.checkpoint.savers.MemorySaver; +import io.github.agentic.ai.graph.agent.action.ActionDecision; +import io.github.agentic.ai.graph.agent.action.ActionType; +import io.github.agentic.ai.graph.agent.hook.taskcompletion.TaskCompletionExceededException; +import io.github.agentic.ai.graph.agent.hook.taskcompletion.TaskCompletionHook; +import io.github.agentic.ai.graph.agent.interceptor.todolist.TodoListInterceptor.Todo; +import io.github.agentic.ai.graph.agent.interceptor.todolist.TodoListInterceptor.TodoStatus; + +import org.junit.jupiter.api.Test; +import org.springframework.ai.support.ToolCallbacks; +import org.springframework.ai.tool.annotation.Tool; +import reactor.adapter.JdkFlowAdapter; +import reactor.core.publisher.Flux; + +import java.util.ArrayList; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Flow; +import java.util.concurrent.atomic.AtomicInteger; + +import static io.github.agentic.ai.graph.agent.hook.taskcompletion.TaskCompletionHook.TerminalAction.NEED_INPUT; +import static io.github.agentic.ai.graph.agent.hook.taskcompletion.TaskCompletionHook.TerminalAction.STOP; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Model-independent tests for {@link TaskCompletionHook} behavior: the nudge re-ask loop + * for unfinished required tasks and the explicit terminal states when the budget is + * exhausted (T36). + */ +class TaskCompletionHookOfflineTest { + + @Test + void noTodosHookStaysSilentAndEndsAfterFirstAnswer() throws Exception { + ScriptedClient client = new ScriptedClient(text("done")); + ReactAgent agent = agent(client, TaskCompletionHook.builder().build()); + + Optional result = agent + .invoke(Map.of("messages", List.of(ChatMessage.user("do the work")))); + + assertTrue(result.isPresent()); + assertEquals(1, client.requests.size()); + assertFalse(result.get().value(TaskCompletionHook.TERMINAL_DECISION_STATE_KEY).isPresent()); + List messages = messagesOf(result.get()); + assertEquals(2, messages.size()); + assertEquals(ChatRole.ASSISTANT, messages.get(messages.size() - 1).role()); + } + + @Test + void completedTodosHookStaysSilentAndEndsAfterFirstAnswer() throws Exception { + ScriptedClient client = new ScriptedClient(text("done")); + ReactAgent agent = agent(client, TaskCompletionHook.builder().build()); + + Optional result = agent.invoke(Map.of("messages", List.of(ChatMessage.user("do the work")), + "todos", todos("completed:run tests"))); + + assertTrue(result.isPresent()); + assertEquals(1, client.requests.size()); + assertFalse(result.get().value(TaskCompletionHook.TERMINAL_DECISION_STATE_KEY).isPresent()); + } + + @Test + void unfinishedTodosNudgeRedirectsBackToTheModel() throws Exception { + // Completes as soon as the second model turn has run, so the run ends normally + // after exactly one nudge. + ScriptedClient client = new ScriptedClient(text("not done yet"), text("all done")); + TaskCompletionHook hook = TaskCompletionHook.builder() + .completionPredicate(state -> client.requests.size() >= 2) + .build(); + ReactAgent agent = agent(client, hook); + + Optional result = agent.invoke(Map.of("messages", List.of(ChatMessage.user("do the work")), + "todos", todos("pending:run tests", "in_progress:write docs"))); + + assertTrue(result.isPresent()); + assertEquals(2, client.requests.size()); + List secondRequest = client.requests.get(1).messages(); + ChatMessage nudge = secondRequest.get(secondRequest.size() - 1); + assertEquals(ChatRole.USER, nudge.role()); + assertTrue(nudge.text().contains("Required tasks are not complete")); + assertTrue(nudge.text().contains("pending: run tests")); + assertTrue(nudge.text().contains("in_progress: write docs")); + // The run ends with the model's own answer, not a terminal notice. + assertFalse(result.get().value(TaskCompletionHook.TERMINAL_DECISION_STATE_KEY).isPresent()); + List messages = messagesOf(result.get()); + assertEquals("all done", messages.get(messages.size() - 1).text()); + } + + @Test + void exhaustedNudgesTerminateWithNeedInputAndUnfinishedRequiredTasks() throws Exception { + ScriptedClient client = new ScriptedClient(text("answer one"), text("answer two"), text("answer three")); + ReactAgent agent = agent(client, TaskCompletionHook.builder().build()); + + Optional result = agent.invoke(Map.of("messages", List.of(ChatMessage.user("do the work")), + "todos", todos("pending:run tests"))); + + assertTrue(result.isPresent()); + assertEquals(3, client.requests.size()); + terminal(result.get(), NEED_INPUT, "UNFINISHED_REQUIRED_TASKS"); + List messages = messagesOf(result.get()); + assertEquals(ChatRole.ASSISTANT, messages.get(messages.size() - 1).role()); + assertTrue(messages.get(messages.size() - 1).text().contains("run tests")); + } + + @Test + void exhaustionCanBeConfiguredToRecordStopInstead() throws Exception { + ScriptedClient client = new ScriptedClient(text("answer one"), text("answer two"), text("answer three")); + ReactAgent agent = agent(client, + TaskCompletionHook.builder().onUnfinishedExhaustion(STOP).build()); + + Optional result = agent.invoke(Map.of("messages", List.of(ChatMessage.user("do the work")), + "todos", todos("pending:run tests"))); + + assertTrue(result.isPresent()); + terminal(result.get(), STOP, "UNFINISHED_REQUIRED_TASKS"); + } + + @Test + void staleTerminalDecisionIsClearedOnAFreshRunWithTheSameThread() throws Exception { + MemorySaver saver = new MemorySaver(); + ScriptedClient exhausted = new ScriptedClient(text("answer one"), text("answer two"), text("answer three")); + ReactAgent agent = agent(exhausted, TaskCompletionHook.builder().build(), null, saver); + + Optional first = agent.invoke( + Map.of("messages", List.of(ChatMessage.user("do the work")), "todos", todos("pending:run tests")), + RunnableConfig.builder().threadId("task-completion-thread").build()); + assertTrue(first.isPresent()); + terminal(first.get(), NEED_INPUT, "UNFINISHED_REQUIRED_TASKS"); + + ScriptedClient recovered = new ScriptedClient(text("done")); + ReactAgent secondAgent = agent(recovered, TaskCompletionHook.builder().build(), null, saver); + + Optional second = secondAgent.invoke( + Map.of("messages", List.of(ChatMessage.user("finished now")), "todos", todos("completed:run tests")), + RunnableConfig.builder().threadId("task-completion-thread").build()); + + assertTrue(second.isPresent()); + assertFalse(second.get().value(TaskCompletionHook.TERMINAL_DECISION_STATE_KEY).isPresent()); + } + + @Test + void runScopedCountersNeverEnterTheCheckpointedState() throws Exception { + ScriptedClient client = new ScriptedClient(text("answer one"), text("answer two"), text("answer three")); + ReactAgent agent = agent(client, TaskCompletionHook.builder().build()); + + Optional result = agent.invoke(Map.of("messages", List.of(ChatMessage.user("do the work")), + "todos", todos("pending:run tests"))); + + assertTrue(result.isPresent()); + assertTrue(result.get().data().keySet().stream().noneMatch(key -> key.contains("task_completion"))); + } + + @Test + void identicalRepeatedFinalAnswersTerminateAsNoProgress() throws Exception { + ScriptedClient client = new ScriptedClient(text("same answer"), text("same answer"), text("same answer")); + ReactAgent agent = agent(client, TaskCompletionHook.builder().build()); + + Optional result = agent.invoke(Map.of("messages", List.of(ChatMessage.user("do the work")), + "todos", todos("pending:run tests"))); + + assertTrue(result.isPresent()); + assertEquals(3, client.requests.size()); + terminal(result.get(), NEED_INPUT, "NO_PROGRESS"); + } + + @Test + void errorExitBehaviorThrowsTaskCompletionExceededExceptionAfterTerminalState() throws Exception { + ScriptedClient client = new ScriptedClient(text("answer one"), text("answer two"), text("answer three")); + ReactAgent agent = agent(client, TaskCompletionHook.builder() + .exitBehavior(TaskCompletionHook.ExitBehavior.ERROR) + .build()); + + TaskCompletionExceededException error = assertThrows(TaskCompletionExceededException.class, + () -> agent.invoke(Map.of("messages", List.of(ChatMessage.user("do the work")), "todos", + todos("pending:run tests")))); + + assertEquals("UNFINISHED_REQUIRED_TASKS", error.getDecision().reasonCode()); + assertEquals(ActionType.NEED_INPUT, error.getDecision().type()); + assertEquals(3, client.requests.size()); + } + + @Test + void repeatedIdenticalToolDecisionsStopWithRepeatedDecision() throws Exception { + ScriptedClient client = new ScriptedClient(toolCall("call-1", "lookup", "{}"), + toolCall("call-2", "lookup", "{}"), toolCall("call-3", "lookup", "{}")); + LookupTools tools = new LookupTools(); + ReactAgent agent = agent(client, TaskCompletionHook.builder().build(), tools); + + Optional result = agent.invoke(Map.of("messages", List.of(ChatMessage.user("look up")))); + + assertTrue(result.isPresent()); + assertEquals(3, client.requests.size()); + // The third identical decision is stopped before it executes. + assertEquals(2, tools.executions.get()); + terminal(result.get(), STOP, "REPEATED_DECISION"); + } + + @Test + void sameToolWithDifferentArgumentsDoesNotTriggerTheRepeatBound() throws Exception { + ScriptedClient client = new ScriptedClient(toolCall("call-1", "lookup", "{\"query\":\"a\"}"), + toolCall("call-2", "lookup", "{\"query\":\"b\"}"), text("done")); + LookupTools tools = new LookupTools(); + ReactAgent agent = agent(client, TaskCompletionHook.builder().build(), tools); + + Optional result = agent.invoke(Map.of("messages", List.of(ChatMessage.user("look up")))); + + assertTrue(result.isPresent()); + assertEquals(2, tools.executions.get()); + assertFalse(result.get().value(TaskCompletionHook.TERMINAL_DECISION_STATE_KEY).isPresent()); + assertEquals("done", messagesOf(result.get()).get(messagesOf(result.get()).size() - 1).text()); + } + + @Test + void unknownToolCallsBeyondTheBoundStopBeforeTheNextModelCall() throws Exception { + ScriptedClient client = new ScriptedClient(toolCall("call-1", "nonexistent", "{}"), + toolCall("call-2", "nonexistent", "{}")); + LookupTools tools = new LookupTools(); + ReactAgent agent = agent(client, TaskCompletionHook.builder().maxUnknownToolCalls(1).build(), tools); + + Optional result = agent.invoke(Map.of("messages", List.of(ChatMessage.user("look up")))); + + assertTrue(result.isPresent()); + assertEquals(2, client.requests.size()); + assertEquals(0, tools.executions.get()); + terminal(result.get(), STOP, "UNKNOWN_TOOL"); + } + + @Test + void unknownToolBoundIsDisabledByDefault() throws Exception { + ScriptedClient client = new ScriptedClient(toolCall("call-1", "nonexistent", "{}"), + toolCall("call-2", "nonexistent", "{}"), text("done anyway")); + LookupTools tools = new LookupTools(); + ReactAgent agent = agent(client, TaskCompletionHook.builder().build(), tools); + + Optional result = agent.invoke(Map.of("messages", List.of(ChatMessage.user("look up")))); + + assertTrue(result.isPresent()); + assertEquals(3, client.requests.size()); + assertFalse(result.get().value(TaskCompletionHook.TERMINAL_DECISION_STATE_KEY).isPresent()); + } + + // ------------------- harness ------------------- + + private static ReactAgent agent(ChatClient client, TaskCompletionHook hook) { + return agent(client, hook, null, null); + } + + private static ReactAgent agent(ChatClient client, TaskCompletionHook hook, LookupTools tools) { + return agent(client, hook, tools, null); + } + + private static ReactAgent agent(ChatClient client, TaskCompletionHook hook, LookupTools tools, + MemorySaver saver) { + var builder = ReactAgent.builder().name("completion-agent").chatClient(client); + if (hook != null) { + builder.hooks(hook); + } + if (tools != null) { + builder.toolRegistry(SpringAiToolAdapter.fromCallbacks(ToolCallbacks.from(tools))); + } + if (saver != null) { + builder.saver(saver); + } + return builder.build(); + } + + static final class LookupTools { + + final AtomicInteger executions = new AtomicInteger(); + + @Tool(name = "lookup", description = "Looks up a value") + public String lookup(String query) { + executions.incrementAndGet(); + return "result for " + query; + } + + } + + private static void terminal(OverAllState state, TaskCompletionHook.TerminalAction expectedType, + String expectedReason) { + Object decision = state.value(TaskCompletionHook.TERMINAL_DECISION_STATE_KEY).orElse(null); + ActionDecision typed = assertInstanceOf(ActionDecision.class, decision); + assertEquals(expectedType.name(), typed.type().name()); + assertEquals(expectedReason, typed.reasonCode()); + } + + @SuppressWarnings("unchecked") + private static List messagesOf(OverAllState state) { + return (List) state.value("messages").orElse(List.of()); + } + + private static List todos(String... specs) { + List result = new ArrayList<>(); + for (String spec : specs) { + int separator = spec.indexOf(':'); + String status = spec.substring(0, separator).toUpperCase(Locale.ROOT); + result.add(new Todo(spec.substring(separator + 1), TodoStatus.valueOf(status))); + } + return result; + } + + private static ChatResponse text(String content) { + return response(content, List.of()); + } + + private static ChatResponse toolCall(String id, String name, String arguments) { + return response("", List.of(new ToolCall(id, name, arguments))); + } + + private static ChatResponse response(String content, List tools) { + return new ChatResponse("response-id", "test-model", List.of(ChatMessage.assistant(content, tools)), tools, + tools.isEmpty() ? FinishReason.STOP : FinishReason.TOOL_CALLS, new Usage(2L, 3L, 5L), Map.of()); + } + + /** + * Neutral scripted client: each model call consumes the next scripted turn. Records + * every request so tests can assert on the conversation the model actually saw. + */ + private static final class ScriptedClient implements ChatClient { + + private final Queue turns; + + final List requests = new CopyOnWriteArrayList<>(); + + ScriptedClient(ChatResponse... responses) { + this.turns = new ArrayDeque<>(List.of(responses)); + } + + @Override + public CompletionStage call(ChatRequest request) { + requests.add(request); + return CompletableFuture.completedFuture(requireNextTurn()); + } + + @Override + public Flow.Publisher stream(ChatRequest request) { + requests.add(request); + return JdkFlowAdapter.publisherToFlowPublisher(Flux.just(ChatEvent.response(requireNextTurn()))); + } + + @Override + public ChatClientCapabilities capabilities() { + return new ChatClientCapabilities(true, true, false, false, true, true); + } + + private ChatResponse requireNextTurn() { + ChatResponse next = turns.poll(); + if (next == null) { + throw new AssertionError("No scripted model turn left"); + } + return next; + } + + } + +} diff --git a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookStreamConsistencyOfflineTest.java b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookStreamConsistencyOfflineTest.java new file mode 100644 index 000000000..3138dfe37 --- /dev/null +++ b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookStreamConsistencyOfflineTest.java @@ -0,0 +1,163 @@ +/* + * Copyright 2025-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.graph.agent.hooks.taskcompletion; + +import io.github.agentic.ai.api.chat.ChatClient; +import io.github.agentic.ai.api.chat.ChatClientCapabilities; +import io.github.agentic.ai.api.chat.ChatEvent; +import io.github.agentic.ai.api.chat.ChatMessage; +import io.github.agentic.ai.api.chat.ChatRequest; +import io.github.agentic.ai.api.chat.ChatResponse; +import io.github.agentic.ai.api.chat.ChatRole; +import io.github.agentic.ai.api.chat.FinishReason; +import io.github.agentic.ai.api.chat.Usage; +import io.github.agentic.ai.graph.NodeOutput; +import io.github.agentic.ai.graph.OverAllState; +import io.github.agentic.ai.graph.agent.ReactAgent; +import io.github.agentic.ai.graph.agent.action.ActionDecision; +import io.github.agentic.ai.graph.agent.hook.taskcompletion.TaskCompletionHook; +import io.github.agentic.ai.graph.agent.interceptor.todolist.TodoListInterceptor.Todo; +import io.github.agentic.ai.graph.agent.interceptor.todolist.TodoListInterceptor.TodoStatus; + +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import reactor.adapter.JdkFlowAdapter; +import reactor.core.publisher.Flux; + +import java.time.Duration; +import java.util.ArrayDeque; +import java.util.List; +import java.util.Map; +import java.util.Queue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.Flow; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Stream vs non-stream consistency of {@link TaskCompletionHook}: the same scripted run + * must reach the same terminal decision and the same message sequence in both modes + * (T36 acceptance: stream/非stream一致性). + */ +class TaskCompletionHookStreamConsistencyOfflineTest { + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void exhaustedNudgeLoopProducesIdenticalOutcomeAcrossModes(boolean streaming) throws Exception { + ScriptedClient client = new ScriptedClient(text("answer one"), text("answer two"), text("answer three")); + ReactAgent agent = agent(client, TaskCompletionHook.builder().build()); + + OverAllState finalState = run(agent, unfinishedInputs(), streaming); + + assertEquals(3, client.requests.size()); + ActionDecision decision = assertInstanceOf(ActionDecision.class, + finalState.value(TaskCompletionHook.TERMINAL_DECISION_STATE_KEY).orElse(null)); + assertEquals("NEED_INPUT", decision.type().name()); + assertEquals("UNFINISHED_REQUIRED_TASKS", decision.reasonCode()); + assertEquals( + List.of(ChatRole.USER, ChatRole.ASSISTANT, ChatRole.USER, ChatRole.ASSISTANT, ChatRole.USER, + ChatRole.ASSISTANT, ChatRole.ASSISTANT), + messagesOf(finalState).stream().map(ChatMessage::role).collect(Collectors.toList())); + } + + @ParameterizedTest + @ValueSource(booleans = { false, true }) + void completedTodosEndAfterOneTurnInBothModes(boolean streaming) throws Exception { + ScriptedClient client = new ScriptedClient(text("all done")); + ReactAgent agent = agent(client, TaskCompletionHook.builder().build()); + + Map inputs = Map.of("messages", List.of(ChatMessage.user("do the work")), "todos", + List.of(new Todo("run tests", TodoStatus.COMPLETED))); + OverAllState finalState = run(agent, inputs, streaming); + + assertEquals(1, client.requests.size()); + assertTrue(finalState.value(TaskCompletionHook.TERMINAL_DECISION_STATE_KEY).isEmpty()); + List messages = messagesOf(finalState); + assertEquals("all done", messages.get(messages.size() - 1).text()); + } + + private static OverAllState run(ReactAgent agent, Map inputs, boolean streaming) + throws Exception { + if (!streaming) { + return agent.invoke(inputs).orElseThrow(); + } + List outputs = agent.stream(inputs).collectList().block(Duration.ofSeconds(10)); + return outputs.get(outputs.size() - 1).state(); + } + + private static Map unfinishedInputs() { + return Map.of("messages", List.of(ChatMessage.user("do the work")), "todos", + List.of(new Todo("run tests", TodoStatus.PENDING))); + } + + private static ReactAgent agent(ChatClient client, TaskCompletionHook hook) { + return ReactAgent.builder().name("stream-consistency-agent").chatClient(client).hooks(hook).build(); + } + + @SuppressWarnings("unchecked") + private static List messagesOf(OverAllState state) { + return (List) state.value("messages").orElse(List.of()); + } + + private static ChatResponse text(String content) { + return new ChatResponse("response-id", "test-model", List.of(ChatMessage.assistant(content)), List.of(), + FinishReason.STOP, new Usage(2L, 3L, 5L), Map.of()); + } + + private static final class ScriptedClient implements ChatClient { + + private final Queue turns; + + final List requests = new CopyOnWriteArrayList<>(); + + ScriptedClient(ChatResponse... responses) { + this.turns = new ArrayDeque<>(List.of(responses)); + } + + @Override + public CompletionStage call(ChatRequest request) { + requests.add(request); + return CompletableFuture.completedFuture(requireNextTurn()); + } + + @Override + public Flow.Publisher stream(ChatRequest request) { + requests.add(request); + return JdkFlowAdapter.publisherToFlowPublisher(Flux.just(ChatEvent.response(requireNextTurn()))); + } + + @Override + public ChatClientCapabilities capabilities() { + return new ChatClientCapabilities(true, true, false, false, true, true); + } + + private ChatResponse requireNextTurn() { + ChatResponse next = turns.poll(); + if (next == null) { + throw new AssertionError("No scripted model turn left"); + } + return next; + } + + } + +} diff --git a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookTest.java b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookTest.java new file mode 100644 index 000000000..fddf774a6 --- /dev/null +++ b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookTest.java @@ -0,0 +1,68 @@ +/* + * Copyright 2025-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.graph.agent.hooks.taskcompletion; + +import io.github.agentic.ai.graph.KeyStrategy; +import io.github.agentic.ai.graph.agent.hook.HookPosition; +import io.github.agentic.ai.graph.agent.hook.JumpTo; +import io.github.agentic.ai.graph.agent.hook.taskcompletion.TaskCompletionHook; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * Model-independent tests pinning the metadata surface of {@link TaskCompletionHook}: + * name, positions, jump destinations, the single declared state key and builder + * validation. + */ +class TaskCompletionHookTest { + + @Test + void declaresOnlyTheTerminalDecisionKeyStrategy() { + var strategies = TaskCompletionHook.builder().build().getKeyStrategys(); + assertEquals(1, strategies.size()); + assertEquals(KeyStrategy.REPLACE, strategies.get(TaskCompletionHook.TERMINAL_DECISION_STATE_KEY)); + } + + @Test + void exposesNamePositionsAndJumpDestinations() { + TaskCompletionHook hook = TaskCompletionHook.builder().build(); + assertEquals("TaskCompletion", hook.getName()); + assertEquals(HookPosition.BEFORE_MODEL, hook.getHookPositions()[0]); + assertEquals(HookPosition.AFTER_MODEL, hook.getHookPositions()[1]); + assertTrue(hook.canJumpTo().containsAll(java.util.List.of(JumpTo.model, JumpTo.end))); + } + + @Test + void terminalDecisionStateKeyIsStable() { + assertEquals("argi.actionDecision.final", TaskCompletionHook.TERMINAL_DECISION_STATE_KEY); + } + + @Test + void builderRejectsInvalidBounds() { + assertThrows(IllegalArgumentException.class, () -> TaskCompletionHook.builder().maxAttempts(0)); + assertThrows(IllegalArgumentException.class, () -> TaskCompletionHook.builder().maxConsecutiveRepeats(1)); + assertThrows(IllegalArgumentException.class, + () -> TaskCompletionHook.builder().maxUnknownToolCalls(0)); + assertThrows(IllegalArgumentException.class, + () -> TaskCompletionHook.builder().deadline(java.time.Duration.ZERO)); + } + +} From a72feac2afd5cf7615240339299fe1fb008cfd13 Mon Sep 17 00:00:00 2001 From: XianReallyHot-ZZH <741788769@qq.com> Date: Sun, 18 Oct 2026 19:06:56 +0800 Subject: [PATCH 2/2] fix(agent): address review nits on task completion hook (T36) Document why TaskCompletionHookDeadlineTest deliberately lives next to the main class (package-private nanoTime seam, mirroring the EffectAwareToolRetryInterceptorTest layout) and defensively normalize a null text in decisionFingerprint so a null can never collide with an answer that literally reads "null". Two new tests pin the behavior: identical textless assistant turns still count as repeats, while a literal "null" answer does not collide with a textless turn. Follow-up to the review on #172 for #139. --- .../taskcompletion/TaskCompletionHook.java | 5 ++- .../TaskCompletionHookDeadlineTest.java | 5 +++ .../TaskCompletionHookOfflineTest.java | 33 +++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHook.java b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHook.java index d566e9944..5a0cb635f 100644 --- a/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHook.java +++ b/argi-agent-framework/src/main/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHook.java @@ -285,7 +285,10 @@ private int trackDecisionFingerprint(ChatMessage message, RunnableConfig config) private String decisionFingerprint(ChatMessage message) { if (!message.hasToolCalls()) { - return "final:" + message.text(); + // Defensive normalization: text() joins content parts and never returns null + // today, but a null must never collide with an answer that literally reads + // "null" — identical textless turns still compare as identical by design. + return "final:" + Objects.requireNonNullElse(message.text(), ""); } return "tools:" + message.toolCalls() .stream() diff --git a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHookDeadlineTest.java b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHookDeadlineTest.java index eeed8feba..c4d3d7f58 100644 --- a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHookDeadlineTest.java +++ b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hook/taskcompletion/TaskCompletionHookDeadlineTest.java @@ -47,6 +47,11 @@ /** * Deadline behavior of {@link TaskCompletionHook} driven through the package-private * {@code nanoTime} test seam. + * + *

This test deliberately lives next to the main class (singular {@code hook} package, + * unlike the public-API tests in {@code hooks.taskcompletion}) because the seam is + * package-private — the same layout as {@code EffectAwareToolRetryInterceptorTest} in + * the tool-retry package.

*/ class TaskCompletionHookDeadlineTest { diff --git a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookOfflineTest.java b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookOfflineTest.java index 8385c5f7a..1ac9656a4 100644 --- a/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookOfflineTest.java +++ b/argi-agent-framework/src/test/java/io/github/agentic/ai/graph/agent/hooks/taskcompletion/TaskCompletionHookOfflineTest.java @@ -205,6 +205,34 @@ void identicalRepeatedFinalAnswersTerminateAsNoProgress() throws Exception { terminal(result.get(), NEED_INPUT, "NO_PROGRESS"); } + @Test + void textlessAssistantAnswersStillCountAsRepeatsWhenIdentical() throws Exception { + ScriptedClient client = new ScriptedClient(textless(), textless()); + ReactAgent agent = agent(client, TaskCompletionHook.builder().maxConsecutiveRepeats(2).build()); + + Optional result = agent.invoke(Map.of("messages", List.of(ChatMessage.user("do the work")), + "todos", todos("pending:run tests"))); + + assertTrue(result.isPresent()); + assertEquals(2, client.requests.size()); + terminal(result.get(), STOP, "REPEATED_DECISION"); + } + + @Test + void textlessAnswerDoesNotCollideWithALiteralNullAnswer() throws Exception { + ScriptedClient client = new ScriptedClient(textless(), text("null"), text("final try")); + ReactAgent agent = agent(client, TaskCompletionHook.builder().maxConsecutiveRepeats(2).build()); + + Optional result = agent.invoke(Map.of("messages", List.of(ChatMessage.user("do the work")), + "todos", todos("pending:run tests"))); + + assertTrue(result.isPresent()); + // A literal "null" answer must not be fingerprint-equal to a textless turn, so + // the repeat bound never fires and the run reaches the exhaustion terminal. + assertEquals(3, client.requests.size()); + terminal(result.get(), NEED_INPUT, "UNFINISHED_REQUIRED_TASKS"); + } + @Test void errorExitBehaviorThrowsTaskCompletionExceededExceptionAfterTerminalState() throws Exception { ScriptedClient client = new ScriptedClient(text("answer one"), text("answer two"), text("answer three")); @@ -345,6 +373,11 @@ private static ChatResponse text(String content) { return response(content, List.of()); } + /** An assistant turn without any text content (text() renders as the empty string). */ + private static ChatResponse textless() { + return response(null, List.of()); + } + private static ChatResponse toolCall(String id, String name, String arguments) { return response("", List.of(new ToolCall(id, name, arguments))); }