Chatbot Interruption

Scope

This document describes the current implementation of the STOP button in the chatbot session. It covers the path from the Razor UI through the C# session/container layer to the Python plan executor.

STOP combines targeted de²generator request cancellation with the existing cooperative plan interruption. It is not the same operation as Terminate.

End-to-end flow

sequenceDiagram
    participant User
    participant Razor as ChatTopButtons.razor
    participant Session as C# Session
    participant Program as AiProgramInstance
    participant Container as AI container
    participant API as Python POST /stopplan
    participant Plan as Active Plan
    participant Agents as IN_PROGRESS goal agents
    participant DE2 as de²generator /v1/cancel
    participant Flag as InputOutput.plan_is_stopped
    participant Goal as Goal.run()
    participant Chatbot as ChatBot2 loop

    User->>Razor: Click STOP icon
    Razor->>Session: StopPlan()
    Session->>Program: StopPlan()
    Program->>Container: StopPlan()
    Container->>API: POST /stopplan
    API->>Plan: cancel_llm_requests()
    Plan->>Agents: Cancel requests concurrently
    Agents->>DE2: POST /v1/cancel with request token
    DE2-->>Agents: cancelled/already_cancelled
    Agents-->>Plan: cancellation results
    API->>Flag: set_stopped(True)
    API-->>Container: 200 OK
    Container-->>Program: completed
    Program-->>Session: completed
    Session-->>Razor: OnDataChanged + StateHasChanged

    Note over Goal,Flag: Current work continues until it reaches a stop check
    Goal->>Flag: is_stopped()
    Flag-->>Goal: True
    Goal-->>Chatbot: Stop scheduling / return
    Chatbot->>Flag: set_stopped(False)
    Chatbot-->>User: Return to input loop

1. Razor entry point

The STOP icon is rendered by ThalabusWeb/RazorComponents/ChatRazorComponents/ChatTopButtons.razor.

The click handler is:

private async Task StopSession() {
    if (SessionData != null) {
        await SessionData.StopPlan();
        StateHasChanged();
    }
}

This handler does not set IsStopping. It only requests that the current plan stop and then refreshes the component.

The neighboring TerminateSession() handler is different: it sets SessionData.IsStopping, calls aiProgram.Stop(...), clears chat state, removes the AI program, removes the session, and navigates away. The STOP button does none of those operations.

2. C# session and program delegation

The call chain is:

ChatTopButtons.razor
  -> Session.StopPlan()
  -> AiProgramInstance.StopPlan()
  -> IAiContainer.StopPlan()

Session.Partial.PlanHandling.cs obtains the current AI program and delegates to it. AiProgramInstance.StopPlan() delegates to its configured AiContainer.

Local/container-backed sessions

AiContainer.StopPlan() sends an authenticated HTTP request to:

POST {ai-container-url}/stopplan

The request has an empty JSON object as its body. It uses PythonApiHttpClientFactory.CreateUserSessionClient(...), so the request carries the user-session bearer token when one is available.

LocalAiContainer.StopPlan() uses the same endpoint through its local base URL.

Remote sessions

RemoteSession.StopPlan() uses the remote session API instead:

POST {session-endpoint}/sessions/session/{session-id}/stop

It sends the remote session API token as a bearer token. This is a different transport boundary from the direct Python container /stopplan route; the remote session service is responsible for forwarding or applying the stop request.

3. Python HTTP endpoint

The direct container route is registered in PythonServer/src/api/v1/session.py:

@app.route('/stopplan', methods=['POST'])
def post_stopplan():
    success, _, error_message, status_code = post_stopplan_data()

The route is included in the authenticated user-session endpoint allowlist in PythonServer/src/api/auth/jwt_middleware.py.

post_stopplan_data() in PythonServer/src/core/base/Main.py first asks the current global plan to cancel active LLM requests, then sets the cooperative stop flag:

plan = get_global_plan()
if plan is not None:
    plan.cancel_llm_requests()
set_stopped(True)

On success it returns HTTP 200 with Plan stopped successfully. If either operation raises an exception, it returns an error response.

No plan ID, goal ID, agent ID, or cancellation token is supplied by C# to this endpoint. The endpoint resolves the current global plan and obtains request tokens from its active goals.

4. Python stop state

PythonServer/src/core/base/InputOutput.py defines:

plan_is_stopped = [False]

The list is used as a mutable container so imported references observe updates. The public helpers are:

def set_stopped(value: bool) -> None:
    plan_is_stopped[0] = value

def is_stopped() -> bool:
    return plan_is_stopped[0]

This is process-global state within the Python service/container. It is not scoped by plan, goal, agent, or request token.

5. Where execution checks the flag

Plan.run() delegates to the root goal:

response = self.plan_goal.run()

Plan.cancel_llm_requests() recursively obtains active goals and dispatches their cancellation through a ThreadPoolExecutor. Each goal is submitted as an independent cancellation task, so multiple active requests are cancelled concurrently.

Goal.get_active_goals() selects only goals whose status is GoalStatus.IN_PROGRESS. GoalStatus.CHILDREN_IN_PROGRESS is a scheduling/container state and is excluded because the child goals own the LLM requests. Not-started, completed, and failed goals are also excluded.

Each selected goal calls its target agent's cancel_llm_request(). The agent sends:

POST {de2generator-uri}/v1/cancel

with the request token in the JSON body:

{"cancellation_token": "<request-token>"}

The token is created and registered when provider_http routes an LLM call through de²generator, and is removed when that call finishes.

If the de²generator-backed request returns HTTP 499, provider_http raises GoalCancelledException immediately from the HTTP response path. The exception is a cancellation control-flow sentinel that bypasses generic except Exception handlers. It propagates through the agent boundary until _run_parallel(), which marks the goal GoalStatus.CANCELLED through set_goal_status().

Setting any goal to GoalStatus.CANCELLED through set_goal_status() automatically propagates cancellation to goals that depend on it, recursively. Dependents are changed only while they are still GoalStatus.NOT_STARTED (the codebase's equivalent of OPEN). Goals already in progress are not changed by dependency propagation; their own request cancellation is handled independently. Callers therefore do not invoke dependency propagation separately.

Goal.run() checks is_stopped():

When the flag becomes true, the goal stops scheduling additional work after the current control point. This prevents later goals from starting, but it does not interrupt arbitrary Python code that is already running.

The plan may be executing child goals concurrently. The executor waits on each submitted future with future.result(). Therefore, if STOP arrives while a child goal is active, the parent can remain blocked until that child returns. The stop flag is then observed by the surrounding scheduling loop.

6. Chatbot loop behavior after STOP

In PythonServer/src/core/extensions/skillsets/ChatBot/ChatBot2.py, the main chatbot loop does this:

execution_response = plan.run()
set_stopped(False)

The stop flag is reset immediately after plan.run() returns. The chatbot then continues its normal response/input-loop handling and sends the updated plan to Blazor.

This means STOP is a one-shot cooperative interruption signal. It does not leave the plan permanently stopped, and it does not shut down the chatbot process.

The initial chatbot setup also clears the flag before starting normal plan execution. A newly created chatbot plan therefore starts with the stop flag false.

What STOP currently does

What STOP currently does not do

Relation to the agent cancellation token

Agents maintain an active request registry. For each de²generator LLM call, provider_http creates a request token, adds it to the de²generator metadata, and registers the token and endpoint on the owning agent. Agent.cancel_llm_request() uses that registry to call de²generator /v1/cancel with the token in the JSON body. The request is removed from the registry in the provider's finally block.

The STOP path now invokes Plan.cancel_llm_requests() from the Python /stopplan handler before setting the global stop flag. The C# STOP handler remains unchanged; it still calls /stopplan and does not carry an individual request token.

Practical timing

The observed timing is therefore:

  1. The user clicks STOP.
  2. The Python stop handler concurrently sends de²generator cancellation requests for active IN_PROGRESS goal agents.
  3. The handler sets the global stop flag.
  4. De²generator-backed LLM calls can be interrupted by de²generator; other active LLM calls, tool calls, or blocking futures may continue until they return or reach their own timeout/error boundary.
  5. The next Goal.run() stop check prevents further scheduling.
  6. The chatbot loop resets the flag and waits for/handles the next input.

The UI request itself is awaited by the Razor handler, but this only waits for the stop endpoint response. It does not mean the active plan execution has already finished when the handler returns.