Skip to content

[BUG] ChatAgent never evaluates response_terminators in streaming mode (stream=True); agent never terminates #4399

Open

Description

@BlueX888

Required prerequisites

What version of camel are you using?

0.2.91a7 (installed from source, commit 24fde60ad739b4be8f8f533fea9ba54256fbc4a2 on master)

System information

  • Installed from source: git clone https://github.com/camel-ai/camel, checked out at 24fde60ad739b4be8f8f533fea9ba54256fbc4a2 (master as of 2026-10-05)
  • Python 3.13.15 (main, Aug 25 2026, 13:50:12) [Clang 22.1.3], darwin (macOS, arm64)
  • No extra dependencies beyond camel's own requirements (the reproduction below uses a fake model backend, no API key, no network)
  • python -c "import sys, camel; print(sys.version, sys.platform); print(camel.__version__)" → 3.13.15 (main, Aug 25 2026, 13:50:12) [Clang 22.1.3 ] darwin / 0.2.91a7

Problem description

ChatAgent accepts response_terminators and, per its constructor docstring, "will keep prompting the model until a terminator signals completion". This works in non-streaming mode, but when the model backend is configured with model_config_dict={'stream': True} (and step()/astep() are called), the configured ResponseTerminators are never consulted at all: no is_terminated() call happens, agent.terminated stays False, and every yielded response carries terminated=False. The "Please continue." re-prompting logic that _step_impl runs when a terminator says "not complete" is also skipped.

The same agent, with the only difference stream=False, terminates on the identical model output and reports the termination reason.

I reproduced this with the identical scripted model output in both modes (terminator: ResponseWordsTerminator(words_dict={"goodbye": 1}), model output: "Goodbye!"). Verbatim output of the script below:

2026-10-11 12:23:54,875 - camel.types.unified_model_type - WARNING - Unknown model 'fake-model': context window size not defined. Defaulting to 999_999_999.
non-streaming: response.terminated = True
non-streaming: agent.terminated   = True
non-streaming: terminator fired   = True
non-streaming: info['termination_reasons'] = ['Word `goodbye` appears 1 times in the 1 message of the response which has reached termination threshold 1.']
non-streaming: info['finish_reasons']       = None
streaming:    response.terminated = False (type: StreamingChatAgentResponse)
streaming:    agent.terminated   = False
streaming:    terminator fired   = False word_count_dict = []
streaming:    info['termination_reasons'] = None
streaming:    info['finish_reasons']       = ['stop']
=== verdict ===
BUG PRESENT: terminator fired in non-streaming mode but was never consulted in streaming mode; agent never terminates.

Note term2._word_count_dict is even still empty ([]) in streaming mode, i.e. the terminator object was never even called.

This also affects in-tree consumers: camel/societies/workforce/role_playing_worker.py:154-162 breaks its chat loop only when assistant_response.terminated is True, so a workforce running on streaming-configured agents can never end its chat via a terminator.

Reproducible example code

The Python snippets (self-contained; fake backend, no API key, no network):

from typing import Any, Dict, List, Optional, Type, Union

from openai.types.chat import ChatCompletion, ChatCompletionMessage
from openai.types.chat.chat_completion import Choice
from openai.types.chat.chat_completion_chunk import (
    ChatCompletionChunk,
    Choice as ChunkChoice,
    ChoiceDelta,
    CompletionUsage,
)

from camel.agents.chat_agent import ChatAgent
from camel.models.base_model import BaseModelBackend
from camel.terminators import ResponseWordsTerminator


def _completion() -> ChatCompletion:
    return ChatCompletion(
        id="chatcmpl-test",
        choices=[
            Choice(
                index=0,
                message=ChatCompletionMessage(role="assistant",
                                             content="Goodbye!"),
                finish_reason="stop",
            )
        ],
        created=1730000000,
        model="fake-model",
        object="chat.completion",
        usage=CompletionUsage(prompt_tokens=10, completion_tokens=2,
                              total_tokens=12),
    )


def _chunks() -> List[ChatCompletionChunk]:
    def chunk(delta, finish=None, usage=None):
        return ChatCompletionChunk(
            id="chatcmpl-test",
            choices=[ChunkChoice(index=0, delta=delta, finish_reason=finish)],
            created=1730000000,
            model="fake-model",
            object="chat.completion.chunk",
            usage=usage,
        )

    return [
        chunk(ChoiceDelta(content="Goodbye!")),
        chunk(ChoiceDelta(), finish="stop"),
        chunk(ChoiceDelta(), usage=CompletionUsage(prompt_tokens=10,
                                                  completion_tokens=2,
                                                  total_tokens=12)),
    ]


class FakeBackend(BaseModelBackend):
    def __init__(self, stream: bool) -> None:
        super().__init__(
            model_type="fake-model",
            model_config_dict={"stream": stream},
            api_key="sk-fake",
        )

    class _TC:
        def encode(self, text): return list(range(len(text) // 3))
        def count_tokens_from_messages(self, messages, **kw): return 10
        def count_tokens(self, text, **kw): return len(text.split())

    token_counter = _TC()

    def run(self, messages, response_format=None, tools=None):
        if self.model_config_dict.get("stream"):

            def gen():
                yield from _chunks()

            return gen()
        return _completion()

    def _run(self, *a, **k):  # pragma: no cover
        raise NotImplementedError

    def _arun(self, *a, **k):  # pragma: no cover
        raise NotImplementedError


def _make_agent(stream: bool):
    backend = FakeBackend(stream)
    terminator = ResponseWordsTerminator(words_dict={"goodbye": 1})
    agent = ChatAgent(
        system_message="End every answer with goodbye.",
        model=backend,
        response_terminators=[terminator],
        stream_accumulate=False,
    )
    return agent, terminator


def main() -> None:
    # --- non-streaming ---
    agent1, term1 = _make_agent(stream=False)
    r1 = agent1.step("say goodbye")
    print("non-streaming: response.terminated =", r1.terminated)
    print("non-streaming: agent.terminated   =", agent1.terminated)
    print("non-streaming: terminator fired   =", term1._terminated)
    print("non-streaming: info['termination_reasons'] =",
          r1.info.get("termination_reasons"))
    print("non-streaming: info['finish_reasons']       =",
          r1.info.get("finish_reasons"))

    # --- streaming (same model output content) ---
    agent2, term2 = _make_agent(stream=True)
    r2 = agent2.step("say goodbye")
    responses = list(r2)
    last = responses[-1]
    print("streaming:    response.terminated =", r2.terminated,
          "(type:", type(r2).__name__ + ")")
    print("streaming:    agent.terminated   =", agent2.terminated)
    print("streaming:    terminator fired   =", term2._terminated,
          "word_count_dict =", term2._word_count_dict)
    print("streaming:    info['termination_reasons'] =",
          last.info.get("termination_reasons"))
    print("streaming:    info['finish_reasons']       =",
          last.info.get("finish_reasons"))

    print("=== verdict ===")
    if not r2.terminated and not term2._terminated and r1.terminated:
        print("BUG PRESENT: terminator fired in non-streaming mode but was "
              "never consulted in streaming mode; agent never terminates.")


if __name__ == "__main__":
    main()

Command lines:

python repro_stream_terminators_ignored.py

Extra dependencies:

none (only camel's own dependencies; openai is already a camel dependency)

Steps to reproduce:

  1. Save the snippet above as repro_stream_terminators_ignored.py.
  2. Run python repro_stream_terminators_ignored.py (no API key needed — the fake backend returns scripted responses/chunks with the same content).
  3. Observe: non-streaming prints terminated = True and a termination_reasons entry; streaming prints terminated = False, terminator fired = False, word_count_dict = [], info['termination_reasons'] = None.

Traceback

No exception is raised — the bug is silent: the terminator is simply never consulted in streaming mode.

Expected behavior

Given the constructor contract for response_terminators (camel/agents/chat_agent.py:424-428):

response_terminators (List[ResponseTerminator], optional): List of :obj:ResponseTerminator to check if task is complete. When set, the agent will keep prompting the model until a terminator signals completion. Note: You must define the termination signal (e.g., a keyword) in your system prompt so the model knows what to output. (default: :obj:None)

— with no streaming carve-out documented — and the step() docstring (camel/agents/chat_agent.py:2921-2923):

If stream is True, returns a StreamingChatAgentResponse that behaves like ChatAgentResponse but can also be iterated for streaming updates.

with StreamingChatAgentResponse documented as providing "the same interface as ChatAgentResponse, so existing code doesn't need to change", including a terminated property (camel/agents/chat_agent.py:239-272):

I would expect that, for the identical model output, a ResponseTerminator that fires in non-streaming mode also fires in streaming mode: the terminator's is_terminated() is called on the final assembled messages, agent.terminated becomes True, and info['termination_reasons'] carries the reason — i.e. streaming and non-streaming step() agree, as they already do for the streaming code's own non-streaming fallback branch. When a terminator says "not complete", the "Please continue." re-prompt behaviour of _step_impl should apply in streaming mode too.

Additional context

Root cause. The streaming paths (_stream_response / _process_stream_chunks_with_accumulator and their async twins) never read self.response_terminators and hard-code terminated=False in the final response at camel/agents/chat_agent.py:4868 and :5998 (plus every intermediate yielded response, e.g. :4633, :5661, :6155). The only is_terminated() call sites in the file are the non-streaming ones: :3132-3136 (_step_impl), :3440-3444 (_astep_non_streaming_task), and :3959-3961 (_step_get_info). Notably, the streaming code's own non-streaming fallback branch (:4668-4682, reached when the backend returns a batch response) does route through _convert_to_chatagent_response → _step_get_info and terminates correctly — the gap is only in the chunk-processing path.

A fix along the lines of running the same terminator pass as _step_get_info over the final assembled message list in _process_stream_chunks_with_accumulator / _aprocess_stream_chunks_with_accumulator (at the record_message sites), setting self.terminated and the termination reason, should make streaming and non-streaming step() agree. I'm happy to open a PR with this approach once the issue is accepted (per CONTRIBUTING.md, PRs reference an accepted issue first).

Related items (searched the Issue Tracker and Discussions before filing; none covers this):

Secondary observation from the same repro: the streaming info dict uses "finish_reasons": ["stop"] and has no termination_reasons, while the non-streaming info dict carries termination_reasons — same agent, same input, different info schema.

Disclosure: this report was drafted with AI assistance (code reading and the reproduction script); the output quoted above is the verbatim result of running that script as-is at the commit stated.

Activity

  1. BlueX888 commented on Oct 11, 2026

    @BlueX888
    Author

    Reproduced this. Root cause: camel/agents/chat_agent.py:4868 and :5998 (parent of the fix): the streaming chunk-processing paths never read self.response_terminators and hard-code terminated=False in the final ChatAgentResponse, so is_terminated() is never called in streaming mode while the non-streaming _step_impl path (:3132-3136) does terminate.

    Minimal reproduction:

    python repro_stream_terminators_ignored.py
    

    Observed output:

    streaming:    response.terminated = False (type: StreamingChatAgentResponse)
    streaming:    agent.terminated   = False
    streaming:    terminator fired   = False word_count_dict = []
    streaming:    info['termination_reasons'] = None
    === verdict ===
    BUG PRESENT: terminator fired in non-streaming mode but was never consulted in streaming mode; agent never terminates.
    

    Fix is ready on BlueX888:fix/prep-streaming-terminators-ignored (commit 5343f00b). Mirrors the non-streaming behavior in _stream_response/_astream_response and their chunk processors/structured-output branches: checks terminators on each completed streamed message, propagates the status to the final streamed response and agent state, and applies the 'Please continue.' continuation loop until a terminator fires or max_iteration is reached.
    Verified with: OPENAI_API_KEY=sk-dummy .venv/bin/pytest test/agents/test_chat_agent.py -k "stream_response_terminator" -q -> 4 passed (test_chat_agent_stream_response_terminators, test_chat_agent_async_stream_response_terminators, test_chat_agent_stream_response_terminator_continuation, test_chat_agent_async_stream_response_terminator_continuation)

    Happy to open the PR once you confirm the approach or assign this to me.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions