Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
/*
* Copyright 2024-2026 Embabel Pty Ltd.
*
* 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
*
* http://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 com.embabel.agent.api.common

/**
* Scope of termination - whether to terminate the current action only
* or the entire agent process.
*/
enum class TerminationScope(val value: String) {
/**
* Terminate the entire agent process.
*/
AGENT("agent"),

/**
* Terminate the current action only, allowing agent to continue with next action.
*/
ACTION("action"),
}

/**
* Signal for graceful termination. When placed on the blackboard,
* the agent or action will terminate at the next natural checkpoint.
*
* For agent termination: checked before each tick() in AbstractAgentProcess.
* For action termination: checked between tool calls in the tool loop.
*
* @param scope Whether to terminate agent or action
* @param reason Human-readable explanation for termination
*/
data class TerminationSignal(
val scope: TerminationScope,
val reason: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/*
* Copyright 2024-2026 Embabel Pty Ltd.
*
* 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
*
* http://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.
*/
@file:JvmName("Termination")

package com.embabel.agent.api.termination

import com.embabel.agent.api.common.TerminationScope
import com.embabel.agent.api.common.TerminationSignal
import com.embabel.agent.core.AgentProcess
import com.embabel.agent.core.EarlyTermination
import com.embabel.agent.core.EarlyTerminationPolicy
import com.embabel.agent.core.ProcessContext
import com.embabel.agent.core.support.AbstractAgentProcess

/**
* Request graceful termination of the entire agent process.
* The agent will terminate at the next natural checkpoint (before next tick).
*
* This works for all action types (LLM-based and simple transformations).
* For immediate termination without waiting for a checkpoint, use
* [com.embabel.agent.api.tool.TerminateAgentException].
*
* @param reason Human-readable explanation for termination
* @see com.embabel.agent.api.tool.TerminateAgentException for immediate termination
*/
fun ProcessContext.terminateAgent(reason: String) {
(agentProcess as AbstractAgentProcess).setTerminationRequest(
TerminationSignal(TerminationScope.AGENT, reason)
)
}

/**
* Request graceful termination of the current action only.
* The action will terminate at the next natural checkpoint (between tool calls),
* and the agent will continue with the next planned action.
*
* **Important:** This graceful termination mechanism only works for LLM-based actions
* that use a tool loop. For simple agents actions (non-LLM), use
* [com.embabel.agent.api.tool.TerminateActionException] instead:
* ```
* throw TerminateActionException("reason")
* ```
*
* @param reason Human-readable explanation for termination
* @see com.embabel.agent.api.tool.TerminateActionException for immediate termination
*/
fun ProcessContext.terminateAction(reason: String) {
(agentProcess as AbstractAgentProcess).setTerminationRequest(
TerminationSignal(TerminationScope.ACTION, reason)
)
}

/**
* Early termination policy that checks for API-driven termination signals.
* Terminates the agent process when a [TerminationSignal] with [TerminationScope.AGENT] scope is found.
*/
internal object TerminationSignalPolicy : EarlyTerminationPolicy {
override val name: String = "TerminationSignal"

override fun shouldTerminate(agentProcess: AgentProcess): EarlyTermination? {
val process = agentProcess as? AbstractAgentProcess ?: return null
val signal = process.terminationRequest
return if (signal != null && signal.scope == TerminationScope.AGENT) {
EarlyTermination(
agentProcess = agentProcess,
error = false,
reason = signal.reason,
policy = this,
)
} else null
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
* Copyright 2024-2026 Embabel Pty Ltd.
*
* 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
*
* http://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 com.embabel.agent.api.tool

/**
* Exception thrown to immediately terminate the entire agent process.
*
* When thrown from a tool, this exception propagates through the tool loop
* and action executor, causing the agent process to terminate immediately.
*
* Use this for immediate termination. For graceful termination that waits
* for natural checkpoints, use [com.embabel.agent.api.common.TerminationSignal]
* via the ProcessContext API.
*
* Example usage:
* ```kotlin
* @LlmTool(description = "Stops the agent when critical error detected")
* fun criticalStop(reason: String): String {
* throw TerminateAgentException("Critical error: $reason")
* }
* ```
*
* @param reason Human-readable explanation for termination
*/
class TerminateAgentException(
val reason: String,
) : RuntimeException(reason), ToolControlFlowSignal

/**
* Exception thrown to immediately terminate the current action only.
*
* When thrown from an action or tool, this exception terminates the current
* action's tool loop but allows the agent to continue with the next planned action.
*
* Use this for immediate action termination. For graceful termination that
* waits for natural checkpoints, use [com.embabel.agent.api.common.TerminationSignal]
* via the ProcessContext API.
*
* Example usage:
* ```kotlin
* @Action
* fun processStep(context: ActionContext): String {
* if (shouldSkip()) {
* throw TerminateActionException("Skipping: condition met")
* }
* // ... normal processing
* }
* ```
*
* @param reason Human-readable explanation for termination
*/
class TerminateActionException(
val reason: String,
) : RuntimeException(reason), ToolControlFlowSignal
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,12 @@ package com.embabel.agent.api.tool
* rather than errors. These exceptions are allowed to propagate through
* [TypedTool.call] without being caught and converted to error results.
*
* Control flow signals are also excluded from retry policies.
*
* Examples include:
* - [com.embabel.agent.core.ReplanRequestedException]
* - [com.embabel.agent.core.hitl.AwaitableResponseException]
* - [TerminateActionException]
* - [TerminateAgentException]
*/
interface ToolControlFlowSignal
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,12 @@ enum class ActionStatusCode {
WAITING,

PAUSED,

/** The action was terminated early via [com.embabel.agent.api.tool.TerminateActionException]. Agent continues. */
TERMINATED,

/** The action requested agent termination via [com.embabel.agent.api.tool.TerminateAgentException]. Agent stops. */
AGENT_TERMINATED,
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@
*/
package com.embabel.agent.core.support

import com.embabel.agent.api.common.TerminationScope
import com.embabel.agent.api.common.TerminationSignal
import com.embabel.agent.api.termination.TerminationSignalPolicy
import com.embabel.agent.api.tool.TerminateActionException
import com.embabel.agent.api.tool.TerminateAgentException
import com.embabel.agent.api.common.PlatformServices
import com.embabel.agent.api.common.StuckHandlingResultCode
import com.embabel.agent.api.common.ToolsStats
Expand Down Expand Up @@ -63,6 +68,19 @@ abstract class AbstractAgentProcess(
override val failureInfo: Any?
get() = _failureInfo

private var _terminationRequest: TerminationSignal? = null

internal val terminationRequest: TerminationSignal?
get() = _terminationRequest

internal fun setTerminationRequest(signal: TerminationSignal) {
_terminationRequest = signal
}

internal fun resetTerminationRequest() {
_terminationRequest = null
}

override val lastWorldState: WorldState?
get() = _lastWorldState

Expand Down Expand Up @@ -229,8 +247,33 @@ abstract class AbstractAgentProcess(

/**
* Should this process be terminated early?
* Also clears any pending termination requests after processing.
*/
protected fun identifyEarlyTermination(): EarlyTermination? {
// Check for API-driven termination signal first
val signalTermination = TerminationSignalPolicy.shouldTerminate(this)
if (signalTermination != null) {
resetTerminationRequest()
logger.debug(
"Process {} terminated by termination signal: {}",
this.id,
signalTermination.reason,
)
platformServices.eventListener.onProcessEvent(signalTermination)
_failureInfo = signalTermination
setStatus(AgentProcessStatusCode.TERMINATED)
return signalTermination
}

// Clear any stale ACTION signal that wasn't consumed by tool loop
// (e.g., set by a simple action without tool loop)
val staleSignal = terminationRequest
if (staleSignal != null && staleSignal.scope == TerminationScope.ACTION) {
logger.debug("Clearing stale ACTION termination signal: {}", staleSignal.reason)
resetTerminationRequest()
}

// Check configured early termination policies
val earlyTermination = processOptions.processControl.earlyTerminationPolicy.shouldTerminate(this)
if (earlyTermination != null) {
logger.debug(
Expand Down Expand Up @@ -374,12 +417,20 @@ abstract class AbstractAgentProcess(
val blackboardObjectsBefore = blackboard.objects.toList()

val timestamp = Instant.now()
val actionStatus = withCurrent {
action.qos.retryTemplate("Action-${action.name}").execute<ActionStatus, Throwable> {
action.execute(
processContext = processContext,
)
val actionStatus = try {
withCurrent {
action.qos.retryTemplate("Action-${action.name}").execute<ActionStatus, Throwable> {
action.execute(
processContext = processContext,
)
}
}
} catch (e: TerminateActionException) {
logger.info("Action {} terminated early: {}", action.name, e.reason)
ActionStatus(Duration.between(timestamp, Instant.now()), ActionStatusCode.TERMINATED)
} catch (e: TerminateAgentException) {
logger.info("Action {} requested agent termination: {}", action.name, e.reason)
ActionStatus(Duration.between(timestamp, Instant.now()), ActionStatusCode.AGENT_TERMINATED)
}
val runningTime = Duration.between(timestamp, Instant.now())
_history += ActionInvocation(
Expand Down Expand Up @@ -436,6 +487,16 @@ abstract class AbstractAgentProcess(
logger.debug("⏳ Process {} action {} paused", id, actionStatus.status)
AgentProcessStatusCode.PAUSED
}

ActionStatusCode.TERMINATED -> {
logger.debug("Process {} action terminated early, continuing", id)
AgentProcessStatusCode.RUNNING
}

ActionStatusCode.AGENT_TERMINATED -> {
logger.debug("Process {} action requested agent termination", id)
AgentProcessStatusCode.TERMINATED
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,16 @@ open class ConcurrentAgentProcess(

protected fun actionStatusToAgentProcessStatus(actionStatuses: List<ActionStatus>): AgentProcessStatusCode =
when {
// Agent termination takes highest priority - stop entire agent
actionStatuses.any { it.status == ActionStatusCode.AGENT_TERMINATED } -> {
val failedCount = actionStatuses.count { it.status == ActionStatusCode.FAILED }
if (failedCount > 0) {
logger.warn("Process {} terminating with {} concurrent failure(s)", id, failedCount)
}
logger.debug("Process {} action requested agent termination", id)
AgentProcessStatusCode.TERMINATED
}

actionStatuses.any { it.status == ActionStatusCode.FAILED } -> {
logger.debug("❌ Process {} action {} failed", id, ActionStatusCode.FAILED)
AgentProcessStatusCode.FAILED
Expand All @@ -131,6 +141,12 @@ open class ConcurrentAgentProcess(
AgentProcessStatusCode.RUNNING
}

// Action termination - agent continues (maps to RUNNING)
actionStatuses.any { it.status == ActionStatusCode.TERMINATED } -> {
logger.debug("Process {} action terminated early, continuing", id)
AgentProcessStatusCode.RUNNING
}

actionStatuses.any { it.status == ActionStatusCode.WAITING } -> {
logger.debug("⏳ Process {} action {} waiting", id, ActionStatusCode.WAITING)
AgentProcessStatusCode.WAITING
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@
import com.embabel.agent.api.event.AgentProcessPlanFormulatedEvent
import com.embabel.agent.api.event.GoalAchievedEvent
import com.embabel.agent.api.event.ReplanRequestedEvent
import com.embabel.agent.api.tool.TerminateActionException
import com.embabel.agent.api.tool.TerminateAgentException
import com.embabel.agent.api.tool.ToolControlFlowSignal
import com.embabel.agent.core.Agent
import com.embabel.agent.core.AgentProcess
Expand Down Expand Up @@ -124,7 +126,7 @@
logger.debug("▶️ Process {} running: {}\n\tPlan: {}", id, worldState, plan.infoString())
}

override fun formulateAndExecutePlan(worldState: WorldState): AgentProcess {

Check failure on line 129 in embabel-agent-api/src/main/kotlin/com/embabel/agent/core/support/SimpleAgentProcess.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 16 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=embabel_embabel-agent&issues=AZ0iCfQSDAKfaaDBr7lS&open=AZ0iCfQSDAKfaaDBr7lS&pullRequest=1537
// Use blacklist to exclude actions that just triggered replan
val plan = planner.bestValuePlanToAnyGoal(
system = agent.planningSystem,
Expand Down Expand Up @@ -182,6 +184,23 @@
)
// Keep status as RUNNING to trigger replanning on next tick
setStatus(AgentProcessStatusCode.RUNNING)
} catch (e: TerminateActionException) {
// Action requested early termination - continue with next action
logger.info(
"Action {} terminated early: {}",
action.name,
e.reason,
)
// Keep status as RUNNING to continue with next action
setStatus(AgentProcessStatusCode.RUNNING)
} catch (e: TerminateAgentException) {
// Agent termination requested - stop the entire process
logger.info(
"Agent process terminated by action {}: {}",
action.name,
e.reason,
)
setStatus(AgentProcessStatusCode.TERMINATED)
} catch (e: Exception) {
if (e is ToolControlFlowSignal) {
// Other control flow signals (e.g., UserInputRequiredException) must propagate
Expand Down
Loading
Loading