Skip to content

Commit c3085e8

Browse files
committed
feat: add reasoning support to conversations
- Introduced a per-conversation reasoning level (`reasoning_effort`) to control the model's thinking capability (off | low | medium | high). - Updated the database schema to include the `reasoning_effort` column in the `conversations` table. - Enhanced the `get_llm` function to accept a `reasoning` parameter, which maps to provider-specific thinking parameters. - Modified conversation creation and update endpoints to handle the new `reasoning_effort` field. - Updated frontend components to allow users to select a reasoning level and display the reasoning process in the chat interface. - Implemented logic to stream reasoning content separately from the main response in the chat.
1 parent 1392dd9 commit c3085e8

12 files changed

Lines changed: 406 additions & 42 deletions

File tree

CLAUDE.md

Lines changed: 7 additions & 2 deletions
Large diffs are not rendered by default.

backend/agentflow/__init__.py

Lines changed: 73 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -413,7 +413,64 @@ def mermaid(diagram: str, *, title: str | None = None) -> None:
413413
_emit({"type": "artifact", "kind": "mermaid", "code": str(diagram), "title": title})
414414

415415

416-
def get_llm(name: str = "default"):
416+
_REASONING_BUDGET = {"low": 1024, "medium": 4096, "high": 12000}
417+
418+
419+
def _norm_reasoning(level) -> "str | None":
420+
"""Normalise a reasoning/think request to None | 'low' | 'medium' | 'high'.
421+
422+
Accepts a level string, a bool (True → 'medium'), or falsy/off values → None.
423+
"""
424+
if level is None or level is False:
425+
return None
426+
if level is True:
427+
return "medium"
428+
s = str(level).strip().lower()
429+
if s in ("", "off", "none", "no", "false", "0", "disabled"):
430+
return None
431+
if s in ("low", "medium", "high"):
432+
return s
433+
if s in ("min", "minimal"):
434+
return "low"
435+
if s in ("max", "maximum"):
436+
return "high"
437+
return "medium"
438+
439+
440+
def _apply_reasoning(extra: dict, provider: str, base_url: "str | None", level: str) -> None:
441+
"""Translate one shared low/medium/high level into the provider-specific
442+
'think' knob (each vendor exposes reasoning differently):
443+
444+
- anthropic → thinking={'type':'enabled','budget_tokens': …}
445+
(Claude also needs temperature=1 and max_tokens > budget)
446+
- openai o-series / gpt-5 (no base_url) → reasoning_effort=<level>
447+
- OpenAI-compatible gateway (base_url set) → extra_body={'enable_thinking': True}
448+
(Qwen3 / GLM / vLLM style — a boolean toggle, so the level only
449+
gates on/off here; tune per-gateway if it supports a budget)
450+
- deepseek → nothing (deepseek-reasoner reasons natively; the text comes
451+
back in additional_kwargs['reasoning_content'])
452+
- ollama → reasoning=True
453+
"""
454+
if provider == "anthropic":
455+
budget = _REASONING_BUDGET[level]
456+
extra["thinking"] = {"type": "enabled", "budget_tokens": budget}
457+
extra["temperature"] = 1
458+
if not extra.get("max_tokens") or extra["max_tokens"] <= budget:
459+
extra["max_tokens"] = budget + 4096
460+
elif provider == "deepseek":
461+
return
462+
elif provider == "ollama":
463+
extra.setdefault("reasoning", True)
464+
else: # openai + OpenAI-compatible gateways
465+
if base_url:
466+
eb = dict(extra.get("extra_body") or {})
467+
eb.setdefault("enable_thinking", True)
468+
extra["extra_body"] = eb
469+
else:
470+
extra.setdefault("reasoning_effort", level)
471+
472+
473+
def get_llm(name: str = "default", reasoning=None):
417474
"""
418475
Return a LangChain chat model.
419476
@@ -422,6 +479,13 @@ def get_llm(name: str = "default"):
422479
normalised to `_`). When several channels serve the
423480
same model id, the highest-priority channel wins
424481
(ties → earliest); credentials come from that channel.
482+
- `reasoning=` → turn on the model's thinking/chain-of-thought at a
483+
shared level: `"low"` / `"medium"` / `"high"` (or
484+
`True` = medium; `None`/`"off"` = disabled). Mapped
485+
per provider (Claude thinking budget, OpenAI
486+
reasoning_effort, gateway enable_thinking, …). The
487+
reasoning text streams back as `reasoning_content`
488+
or a `<think>` block — see the chat templates.
425489
426490
Available model ids can be enumerated with `list_llms()`. Returns None outside
427491
the platform.
@@ -454,6 +518,10 @@ def get_llm(name: str = "default"):
454518
extra.setdefault("timeout", 60)
455519
extra.setdefault("max_retries", 1)
456520

521+
_level = _norm_reasoning(reasoning)
522+
if _level:
523+
_apply_reasoning(extra, provider, base_url, _level)
524+
457525
if provider == "anthropic":
458526
from langchain_anthropic import ChatAnthropic
459527
return ChatAnthropic(model=model, api_key=api_key, **extra)
@@ -780,6 +848,7 @@ def get_agent(
780848
system_prompt: str | None = None,
781849
llm_name: str = "default",
782850
tools: list | None = None,
851+
reasoning=None,
783852
):
784853
"""
785854
Return a ready-to-use ReAct agent (LangGraph create_react_agent).
@@ -805,7 +874,7 @@ async def run(input: dict) -> dict:
805874
import inspect
806875
from langgraph.prebuilt import create_react_agent
807876

808-
llm = get_llm(llm_name)
877+
llm = get_llm(llm_name, reasoning=reasoning)
809878
if llm is None:
810879
raise RuntimeError(
811880
"No LLM configured. Add one in AgentFlow Settings before calling get_agent()."
@@ -865,6 +934,7 @@ def get_deep_agent(
865934
system_prompt: str | None = None,
866935
llm_name: str = "default",
867936
tools: list | None = None,
937+
reasoning=None,
868938
**kwargs,
869939
):
870940
"""
@@ -899,7 +969,7 @@ async def run(input: dict) -> dict:
899969
"baseline venv; if it's missing, add 'deepagents' to requirements.txt."
900970
) from e
901971

902-
llm = get_llm(llm_name)
972+
llm = get_llm(llm_name, reasoning=reasoning)
903973
if llm is None:
904974
raise RuntimeError(
905975
"No LLM configured. Add one in AgentFlow Settings before calling get_deep_agent()."
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
"""add conversations.reasoning_effort
2+
3+
Adds a per-conversation reasoning/think level (off | low | medium | high),
4+
threaded into each run's input as input["reasoning"] and mapped to the model's
5+
provider-specific thinking parameter by agentflow.get_llm(reasoning=...).
6+
7+
Plain add_column (a pure add needs no sqlite table-recreate) + inspector guard,
8+
so a DB that already has the column (e.g. a pre-Alembic DB healed via create_all,
9+
or one that got it through an interim path) is skipped rather than hitting a
10+
duplicate-column error. Same defensive shape as 0002/0003.
11+
"""
12+
from typing import Union
13+
14+
from alembic import op
15+
import sqlalchemy as sa
16+
17+
18+
# revision identifiers, used by Alembic.
19+
revision: str = "0004"
20+
down_revision: Union[str, None] = "0003"
21+
branch_labels = None
22+
depends_on = None
23+
24+
25+
def upgrade() -> None:
26+
bind = op.get_bind()
27+
insp = sa.inspect(bind)
28+
cols = [c["name"] for c in insp.get_columns("conversations")]
29+
if "reasoning_effort" not in cols:
30+
op.add_column(
31+
"conversations",
32+
sa.Column(
33+
"reasoning_effort",
34+
sa.String(length=16),
35+
nullable=False,
36+
server_default="off",
37+
),
38+
)
39+
40+
41+
def downgrade() -> None:
42+
op.drop_column("conversations", "reasoning_effort")

backend/app/models.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,10 @@ class Conversation(Base):
262262
script_id = Column(String, ForeignKey("scripts.id", ondelete="CASCADE"), nullable=False)
263263
title = Column(String(500), default="New conversation")
264264
context_turns = Column(Integer, default=10)
265+
# Per-conversation reasoning/think level: off | low | medium | high. Threaded
266+
# into each run's input as input["reasoning"] and mapped to the model's
267+
# provider-specific thinking knob by agentflow.get_llm(reasoning=...).
268+
reasoning_effort = Column(String(16), default="off", server_default="off")
265269
created_at = Column(DateTime, default=datetime.utcnow)
266270
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
267271

backend/app/routers/conversations.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ def create_conversation(body: ConversationCreate, db: Session = Depends(get_db))
5151
script_id=body.script_id,
5252
title=body.title,
5353
context_turns=body.context_turns,
54+
reasoning_effort=body.reasoning_effort or "off",
5455
)
5556
db.add(conv)
5657
db.commit()
@@ -75,6 +76,8 @@ def update_conversation(conv_id: str, body: ConversationUpdate, db: Session = De
7576
conv.title = body.title
7677
if body.context_turns is not None:
7778
conv.context_turns = body.context_turns
79+
if body.reasoning_effort is not None:
80+
conv.reasoning_effort = body.reasoning_effort
7881
conv.updated_at = datetime.utcnow()
7982
db.commit()
8083
db.refresh(conv)
@@ -154,10 +157,15 @@ async def chat_start(conv_id: str, body: ConverseChatStartRequest, db: Session =
154157
history_slice = prior[-(conv.context_turns * 2):]
155158
history = [{"role": m.role, "content": m.content} for m in history_slice]
156159

157-
# Create execution row
160+
# Create execution row. Thread the conversation's reasoning level into the
161+
# input so the script can pass it to get_llm(reasoning=input.get("reasoning")).
158162
exc = Execution(
159163
script_id=conv.script_id,
160-
input_data={"message": body.message, "history": history},
164+
input_data={
165+
"message": body.message,
166+
"history": history,
167+
"reasoning": conv.reasoning_effort or "off",
168+
},
161169
)
162170
db.add(exc)
163171
db.commit()

backend/app/schemas.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,18 +524,21 @@ class ConversationCreate(BaseModel):
524524
script_id: str
525525
title: str = "New conversation"
526526
context_turns: int = 10
527+
reasoning_effort: str = "off"
527528

528529

529530
class ConversationUpdate(BaseModel):
530531
title: Optional[str] = None
531532
context_turns: Optional[int] = None
533+
reasoning_effort: Optional[str] = None
532534

533535

534536
class ConversationSummary(BaseModel):
535537
id: str
536538
script_id: str
537539
title: str
538540
context_turns: int
541+
reasoning_effort: str = "off"
539542
created_at: datetime
540543
updated_at: datetime
541544

backend/services/execution_engine.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
import subprocess
1818
import sys
1919
import threading
20+
import time
2021
from collections import deque
2122
from datetime import datetime
2223
from pathlib import Path
@@ -34,6 +35,19 @@
3435
MAX_CONCURRENT: int = int(os.getenv("AGENTFLOW_MAX_CONCURRENT", "5"))
3536
EXECUTION_TIMEOUT: float = float(os.getenv("AGENTFLOW_EXECUTION_TIMEOUT", "600"))
3637

38+
# Lightweight timing diagnostics printed to the backend console (uvicorn / F5),
39+
# so a slow run can be split into queue-wait / prep / python-cold-start-imports /
40+
# script(LLM) without digging through the per-run logs in the DB. The heavy,
41+
# usually-dominant cost is `cold_import` — every run spawns a fresh python that
42+
# re-imports the whole langchain/langgraph stack (there is no warm worker pool).
43+
# Toggle off with AGENTFLOW_PROFILE=0.
44+
_PROFILE: bool = os.getenv("AGENTFLOW_PROFILE", "1").lower() not in ("0", "false", "no", "")
45+
46+
47+
def _prof(execution_id: str, msg: str) -> None:
48+
if _PROFILE:
49+
print(f"[agentflow] [{execution_id[:8]}] {msg}", flush=True)
50+
3751
# lazy-init so it's created inside the running event loop
3852
_semaphore: asyncio.Semaphore | None = None
3953

@@ -195,6 +209,10 @@ def _emit(d):
195209
async def _main():
196210
197211
import agentflow as _af
212+
# Signal that python cold-start + base imports are done, so the engine can
213+
# split process-boot cost from script(LLM) cost. Unknown event type -> the
214+
# engine's drain loop ignores it (never shown in the Logs panel).
215+
_emit({{"type": "boot"}})
198216
199217
# Zero-intrusion execution tracing: emits __AGENTFLOW__ trace events
200218
# for every LangGraph node, tool call, and agent action. User scripts
@@ -253,6 +271,8 @@ async def _run():
253271
async def start_execution(execution_id: str) -> None:
254272
db = SessionLocal()
255273
slot_acquired = False
274+
_t_enter = time.perf_counter()
275+
_t_slot = _t_spawn = _t_enter
256276
try:
257277
exc_row: Execution = db.query(Execution).filter_by(id=execution_id).first()
258278
if not exc_row:
@@ -269,6 +289,7 @@ async def start_execution(execution_id: str) -> None:
269289

270290
await _get_semaphore().acquire()
271291
slot_acquired = True
292+
_t_slot = time.perf_counter()
272293

273294
# re-read: may have been cancelled while waiting in queue
274295
db.refresh(exc_row)
@@ -493,6 +514,12 @@ def _blob(model: str, ch) -> str:
493514
**popen_kwargs,
494515
)
495516
_procs[execution_id] = proc
517+
_t_spawn = time.perf_counter()
518+
_prof(execution_id, (
519+
f"spawned pid={proc.pid} "
520+
f"(queue_wait={_t_slot - _t_enter:.2f}s, prep={_t_spawn - _t_slot:.2f}s, "
521+
f"venv={'yes' if venv_exists(exc_row.script_id) else 'no→backend-py'})"
522+
))
496523

497524
def _pump(stream, is_stderr: bool):
498525
try:
@@ -510,14 +537,22 @@ def _pump(stream, is_stderr: bool):
510537
result_data: Any = None
511538
error_data: dict | None = None
512539
eof_count = 0
540+
first_output_at: float | None = None
541+
result_at: float | None = None
513542

514543
async def _drain():
515-
nonlocal result_data, error_data, eof_count
544+
nonlocal result_data, error_data, eof_count, first_output_at, result_at
516545
while eof_count < 2:
517546
is_stderr, line = await queue.get()
518547
if line is None:
519548
eof_count += 1
520549
continue
550+
if first_output_at is None:
551+
first_output_at = time.perf_counter()
552+
_prof(execution_id, (
553+
f"first output +{first_output_at - _t_spawn:.2f}s "
554+
f"(python cold-start + imports)"
555+
))
521556
if line.startswith(_PREFIX):
522557
try:
523558
payload = json.loads(line[len(_PREFIX):])
@@ -572,6 +607,7 @@ async def _drain():
572607
await ws_manager.send(execution_id, payload)
573608
elif t == "result":
574609
result_data = payload.get("data")
610+
result_at = time.perf_counter()
575611
elif t == "error":
576612
error_data = payload
577613
# Persist the crash as a log too, so it shows in the Logs
@@ -607,6 +643,11 @@ async def _drain():
607643
_procs.pop(execution_id, None)
608644

609645
timeout_msg = f"Execution timed out after {EXECUTION_TIMEOUT:.0f}s"
646+
_prof(execution_id, (
647+
f"TIMEOUT after {EXECUTION_TIMEOUT:.0f}s | "
648+
f"cold_import={'n/a' if first_output_at is None else f'{first_output_at - _t_spawn:.2f}s'} "
649+
f"(no result before timeout)"
650+
))
610651
exc_row = db.query(Execution).filter_by(id=execution_id).first()
611652
exc_row.status = "failed"
612653
exc_row.error = timeout_msg
@@ -655,6 +696,19 @@ async def _drain():
655696
})
656697
db.commit()
657698

699+
_t_end = time.perf_counter()
700+
if first_output_at is not None:
701+
_cold = first_output_at - _t_spawn
702+
_script = (result_at or _t_end) - first_output_at
703+
else:
704+
_cold = _t_end - _t_spawn # process produced no output at all
705+
_script = 0.0
706+
_prof(execution_id, (
707+
f"done status={exc_row.status} rc={proc.returncode} | "
708+
f"queue_wait={_t_slot - _t_enter:.2f}s prep={_t_spawn - _t_slot:.2f}s "
709+
f"cold_import={_cold:.2f}s script={_script:.2f}s total={_t_end - _t_slot:.2f}s"
710+
))
711+
658712
await ws_manager.send(execution_id, {
659713
"type": "status",
660714
"status": exc_row.status,

0 commit comments

Comments
 (0)