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