Skip to content

Commit e36f312

Browse files
committed
feat(pipeline): add keep-first-think-only toggle for multi-round CoT
New "仅保留第一条思维链" option under output.misc, effective only when remove-think is enabled. When on, the first LLM round keeps its chain-of-thought (visible to the user) while subsequent tool-call rounds strip CoT. - output.yaml: add keep-first-think-only boolean field - default-pipeline-config.json: add default false - localagent.py: pre-loop uses _strip_think_first_round (=False when keep-first), tool-loop uses raw remove_think (=True) - ws_client.py: fallback_after_round + round counter to keep stream_id alive across rounds so the next round reuses the same stream - respback.py: stash pipeline_config on message_event for adapter access - wecombot.py: read config and set fallback_after_round=1 when enabled
1 parent 36c8341 commit e36f312

6 files changed

Lines changed: 57 additions & 8 deletions

File tree

src/langbot/libs/wecom_ai_bot_api/ws_client.py

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ def __init__(
102102
self._feedback_sessions: dict[str, dict] = {} # feedback_id -> {msg_id, user_id, chat_id, stream_id, req_id}
103103
# msg_id -> feedback_id (for associating feedback with message)
104104
self._msg_feedback_ids: dict[str, str] = {} # msg_id -> feedback_id
105+
# Round counter per msg_id for keep-first-think-only: when
106+
# fallback_after_round >= 1, the stream entry is NOT cleared on
107+
# end-of-round so the next round can reuse the same stream_id.
108+
self._stream_rounds: dict[str, int] = {} # msg_id -> round count
109+
self.fallback_after_round: int = 0 # 0 = disabled
105110

106111
# ── Public API ──────────────────────────────────────────────────
107112

@@ -293,9 +298,20 @@ async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = Fa
293298
await self.reply_stream(req_id, stream_id, content, finish=is_final, feedback_id=feedback_id)
294299
self._stream_last_content[msg_id] = content
295300
if is_final:
296-
self._stream_ids.pop(msg_id, None)
297-
self._stream_last_content.pop(msg_id, None)
298-
self._stream_sessions.pop(msg_id, None)
301+
# When fallback_after_round is set, keep the stream entry
302+
# alive across rounds so the next LLM round can reuse
303+
# the same stream_id. Only clear when the round counter
304+
# has crossed the threshold (final round).
305+
round_idx = self._stream_rounds.get(msg_id, 0) + 1
306+
self._stream_rounds[msg_id] = round_idx
307+
if self.fallback_after_round > 0 and round_idx < self.fallback_after_round:
308+
# Keep stream alive for the next round
309+
pass
310+
else:
311+
self._stream_ids.pop(msg_id, None)
312+
self._stream_last_content.pop(msg_id, None)
313+
self._stream_sessions.pop(msg_id, None)
314+
self._stream_rounds.pop(msg_id, None)
299315
return True
300316
except Exception:
301317
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')

src/langbot/pkg/pipeline/respback/respback.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,14 @@ async def process(self, query: pipeline_query.Query, stage_inst_name: str) -> en
4646
try:
4747
if await query.adapter.is_stream_output_supported() and has_chunks:
4848
is_final = [msg.is_final for msg in query.resp_messages][0]
49+
self.ap.logger.info(f'respback: calling reply_message_chunk, is_final={is_final}')
50+
# Stash pipeline config on the message event so the
51+
# platform adapter can read misc toggles like
52+
# keep-first-think-only without threading query through.
53+
try:
54+
query.message_event._langbot_pipeline_config = query.pipeline_config
55+
except Exception:
56+
pass
4957
await query.adapter.reply_message_chunk(
5058
message_source=query.message_event,
5159
bot_message=query.resp_messages[-1],

src/langbot/pkg/platform/sources/wecombot.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,15 @@ async def reply_message_chunk(
368368
_ws_mode = not self.config.get('enable-webhook', False)
369369

370370
if _ws_mode:
371+
# Read keep-first-think-only from pipeline config stashed by respback
372+
try:
373+
_pipeline_cfg = getattr(message_source, '_langbot_pipeline_config', None) or {}
374+
_misc = (_pipeline_cfg.get('output') or {}).get('misc') or {}
375+
_keep_first = bool(_misc.get('remove-think', False) and _misc.get('keep-first-think-only', False))
376+
self.bot.fallback_after_round = 1 if _keep_first else 0
377+
except Exception:
378+
pass
379+
371380
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
372381
if not success and is_final:
373382
event = message_source.source_platform_object

src/langbot/pkg/provider/runners/localagent.py

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -471,7 +471,12 @@ async def run(
471471

472472
# Safely resolve the "remove think blocks" toggle. ``output.misc``
473473
# is not guaranteed to exist on every pipeline configuration.
474-
remove_think = ((query.pipeline_config.get('output') or {}).get('misc') or {}).get('remove-think', False)
474+
_misc = (query.pipeline_config.get('output') or {}).get('misc') or {}
475+
remove_think = _misc.get('remove-think', False)
476+
# ``keep-first-think-only``: only effective when ``remove-think`` is on.
477+
# Round 1 (pre-loop) keeps CoT; rounds 2+ (tool-loop) strip it.
478+
keep_first_think_only = _misc.get('keep-first-think-only', False)
479+
_strip_think_first_round = remove_think and not keep_first_think_only
475480

476481
# Build ordered candidate list (primary + fallbacks)
477482
candidates = await self._get_model_candidates(query)
@@ -490,19 +495,19 @@ async def run(
490495
candidates,
491496
req_messages,
492497
query.use_funcs,
493-
remove_think,
498+
_strip_think_first_round,
494499
)
495500
final_msg = msg
496501
else:
497502
# Streaming: invoke with fallback
498-
stream_accumulator = _StreamAccumulator(msg_sequence=1, remove_think=remove_think)
503+
stream_accumulator = _StreamAccumulator(msg_sequence=1, remove_think=_strip_think_first_round)
499504

500505
stream_src, use_llm_model = await self._invoke_stream_with_fallback(
501506
query,
502507
candidates,
503508
req_messages,
504509
query.use_funcs,
505-
remove_think,
510+
_strip_think_first_round,
506511
)
507512
async for msg in stream_src:
508513
chunk = stream_accumulator.add(msg)

src/langbot/templates/default-pipeline-config.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,8 @@
107107
"at-sender": true,
108108
"quote-origin": true,
109109
"track-function-calls": false,
110-
"remove-think": false
110+
"remove-think": false,
111+
"keep-first-think-only": false
111112
}
112113
}
113114
}

src/langbot/templates/metadata/pipeline/output.yaml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,4 +145,14 @@ stages:
145145
type: boolean
146146
required: true
147147
default: false
148+
- name: keep-first-think-only
149+
label:
150+
en_US: Keep Only First CoT
151+
zh_Hans: 仅保留第一条思维链
152+
description:
153+
en_US: 'Only effective when "Remove CoT" is enabled. Keeps the chain-of-thought on the first LLM round but strips it from all subsequent rounds in a multi-round tool-call loop.'
154+
zh_Hans: '仅在启用"删除思维链"时生效。多轮工具调用中,保留第一轮的思维链,后续轮次的思维链一律删除。'
155+
type: boolean
156+
required: true
157+
default: false
148158

0 commit comments

Comments
 (0)