Repository navigation
Description
Required prerequisites
- I have read the documentation https://camel-ai.github.io/camel/camel.html.
- I have searched the Issue Tracker and Discussions that this hasn't already been reported. (+1 or comment there if it has.)
- Consider asking first in a Discussion.
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 at24fde60ad739b4be8f8f533fea9ba54256fbc4a2(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.pyExtra dependencies:
none (only camel's own dependencies; openai is already a camel dependency)
Steps to reproduce:
- Save the snippet above as
repro_stream_terminators_ignored.py. - Run
python repro_stream_terminators_ignored.py(no API key needed — the fake backend returns scripted responses/chunks with the same content). - Observe: non-streaming prints
terminated = Trueand atermination_reasonsentry; streaming printsterminated = 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:
ResponseTerminatorto 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):
- [BUG] ChatAgent.clone() shares mutable response terminators across clones #4198 — closed; different bug (
ChatAgent.clone()sharing mutable terminator lists) - ResponseWordsTerminator.reset() sets _word_count_dict to the wrong type, crashes on reuse #4181 — closed; different bug (
ResponseWordsTerminator.reset()wrong_word_count_dicttype)
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
BlueX888 commentedon Oct 11, 2026
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:
Observed output:
Fix is ready on
BlueX888:fix/prep-streaming-terminators-ignored(commit5343f00b). 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.