Jodinho commited on
Commit
7841ad6
Β·
1 Parent(s): 95cee8d

fix: router - remove response_format for Qwen thinking model compatibility

Browse files

qwen/qwen3.6-27b rejects response_format={'type':'json_object'} with
a 400 json_validate_failed error, causing both primary and fallback
router calls to fail and always defaulting to PATH_A.

Fixes:
- Remove response_format from groq_call in classify_query()
- Add _extract_json_from_text() to handle:
* <think>...</think> blocks that Qwen prepends to output
* Regex fallback extraction if json.loads fails
- Increase router max_tokens 80 -> 500 to allow full thinking output
- Add _VALID_CLASSIFICATIONS guard to catch garbage model outputs
- Config: qwen/qwen3.6-27b set as route model (user preference)

Files changed (2) hide show
  1. services/agent_loop.py +184 -78
  2. services/prompts.py +5 -1
services/agent_loop.py CHANGED
@@ -48,7 +48,7 @@ from services.tools import REAL_ESTATE_TOOLS, COMMERCIAL_TOOLS
48
 
49
  logger = setup_logger(__name__)
50
 
51
- MAX_TOOL_ROUNDS = 4 # Maximum tool-call rounds before forcing synthesis
52
 
53
  # ─── STATIC RESPONSES ─────────────────────────────────────────────────────────
54
 
@@ -69,41 +69,96 @@ _REPLY_FALLBACK = (
69
 
70
  # ─── ROUTER ───────────────────────────────────────────────────────────────────
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  async def classify_query(user_query: str) -> str:
73
  """
74
- Use a small, fast model to classify the query into one of 6 route tags.
75
- Fix #4: Previously used a 20B reasoning model (484ms). Now uses the
76
- lightweight GROQ_ROUTE_MODEL (default: llama-3.1-8b-instant, ~120ms).
 
 
 
 
77
  """
78
  messages = [
79
  {"role": "system", "content": ROUTER_PROMPT},
80
  {"role": "user", "content": user_query},
81
  ]
82
- # Try primary route model, fall back to secondary
83
  for model in [GROQ_ROUTE_MODEL, GROQ_FALLBACK_ROUTE_MODEL]:
84
  try:
85
  res = await groq_call(
86
  model=model,
87
  messages=messages,
88
- max_tokens=80,
89
  temperature=0.0,
90
- response_format={"type": "json_object"},
91
  )
92
  raw = res.choices[0].message.content or "{}"
93
- data = json.loads(raw)
94
- classification = data.get("classification", "PATH_A")
95
- logger.info(f"[router] {model} -> {classification}: {data.get('reason', '')}")
 
 
 
 
 
 
 
 
 
96
  return classification
 
97
  except Exception as exc:
98
  if is_structural_error(exc):
99
  logger.warning(f"[router] structural error on {model}: {exc}")
100
  else:
101
  logger.warning(f"[router] {model} failed, trying fallback: {exc}")
102
- return "PATH_A" # Safe default
 
103
 
104
 
105
  # ─── AGENTIC TOOL LOOP ────────────────────────────────────────────────────────
106
 
 
107
  async def run_agent_loop(
108
  messages: list[dict],
109
  active_tools: list | None,
@@ -144,15 +199,19 @@ async def run_agent_loop(
144
  # or schema mismatch β€” recover by executing the tool directly.
145
  fn, fn_args = parse_failed_generation(failed_gen)
146
  if fn and isinstance(fn_args, dict):
147
- logger.info(f"[agent_loop] recovering tool={fn} from failed_generation")
 
 
148
  tool_result = await execute_tool_by_name(fn, fn_args)
149
  tool_results.append({"tool": fn, "args": fn_args})
150
- messages.append({
151
- "tool_call_id": f"recov_{int(time.time())}",
152
- "role": "tool",
153
- "name": fn,
154
- "content": compress_tool_output(fn, tool_result),
155
- })
 
 
156
  # After injecting result, break to top of outer loop to retry
157
  break
158
  logger.warning(f"[agent_loop] {model} error round={round_num}: {exc}")
@@ -171,18 +230,20 @@ async def run_agent_loop(
171
  if finish_reason == "tool_calls":
172
  messages.append(normalize_assistant_message(response_message)) # Fix #5
173
  for tc in response_message.tool_calls:
174
- fn = tc.function.name
175
  args = parse_tool_args(tc.function.arguments)
176
  logger.info(f"[agent_loop] executing tool={fn} args={args}")
177
 
178
  result = await execute_tool_by_name(fn, args)
179
  tool_results.append({"tool": fn, "args": args})
180
- messages.append({
181
- "tool_call_id": tc.id,
182
- "role": "tool",
183
- "name": fn,
184
- "content": compress_tool_output(fn, result),
185
- })
 
 
186
  # Continue loop β€” model may want more tools or synthesis next round
187
 
188
  # ── Direct text reply β€” done ──────────────────────────────────────────
@@ -193,13 +254,15 @@ async def run_agent_loop(
193
  logger.warning("[agent_loop] max rounds reached, forcing synthesis")
194
  for model in [GROQ_SYNTHESIS_MODEL, GROQ_FALLBACK_SYNTHESIS_MODEL]:
195
  try:
196
- synth_messages = list(messages) + [{
197
- "role": "system",
198
- "content": (
199
- "Synthesize the real estate data gathered above into a clear, "
200
- "concise Markdown reply for the user. Do NOT call any more tools."
201
- ),
202
- }]
 
 
203
  final_res = await groq_call(
204
  model=model,
205
  messages=synth_messages,
@@ -215,6 +278,7 @@ async def run_agent_loop(
215
  fn, fn_args = parse_failed_generation(failed_gen)
216
  if fn == "generate_data_export" and isinstance(fn_args, dict):
217
  from services.appwrite_service import upload_document_to_appwrite
 
218
  content = fn_args.get("content", "")
219
  upload_res = await upload_document_to_appwrite(content, "md")
220
  dl_url = upload_res.get("download_url", "")
@@ -228,6 +292,7 @@ async def run_agent_loop(
228
 
229
  # ─── STREAMING AGENT LOOP (for SSE) ───────────────────────────────────────────
230
 
 
231
  async def run_agent_loop_streaming(
232
  messages: list[dict],
233
  active_tools: list | None,
@@ -269,12 +334,14 @@ async def run_agent_loop_streaming(
269
  if fn and isinstance(fn_args, dict):
270
  tool_result = await execute_tool_by_name(fn, fn_args)
271
  tool_results.append({"tool": fn, "args": fn_args})
272
- messages.append({
273
- "tool_call_id": f"recov_{int(time.time())}",
274
- "role": "tool",
275
- "name": fn,
276
- "content": compress_tool_output(fn, tool_result),
277
- })
 
 
278
  yield {"type": "tool_call", "tool": fn, "args": fn_args}
279
  break
280
  logger.warning(f"[stream_loop] {model} error round={round_num}: {exc}")
@@ -286,17 +353,19 @@ async def run_agent_loop_streaming(
286
  if has_tool_calls:
287
  messages.append(normalize_assistant_message(response_message))
288
  for tc in response_message.tool_calls:
289
- fn = tc.function.name
290
  args = parse_tool_args(tc.function.arguments)
291
  yield {"type": "tool_call", "tool": fn, "args": args}
292
  result = await execute_tool_by_name(fn, args)
293
  tool_results.append({"tool": fn, "args": args})
294
- messages.append({
295
- "tool_call_id": tc.id,
296
- "role": "tool",
297
- "name": fn,
298
- "content": compress_tool_output(fn, result),
299
- })
 
 
300
  else:
301
  # Model returned a direct text reply β€” stream it token by token
302
  if response_message.content:
@@ -308,10 +377,12 @@ async def run_agent_loop_streaming(
308
  # Forced synthesis with streaming
309
  for model in [GROQ_SYNTHESIS_MODEL, GROQ_FALLBACK_SYNTHESIS_MODEL]:
310
  try:
311
- synth_messages = list(messages) + [{
312
- "role": "system",
313
- "content": "Synthesize the real estate data gathered above into a clear Markdown reply. Do NOT call any more tools.",
314
- }]
 
 
315
 
316
  def _stream_call():
317
  return groq_client.chat.completions.create(
@@ -323,13 +394,17 @@ async def run_agent_loop_streaming(
323
  )
324
 
325
  from services.groq_client import groq_client as _gc
326
- stream = await loop.run_in_executor(None, lambda: _gc.chat.completions.create(
327
- model=model,
328
- messages=synth_messages,
329
- temperature=0.2,
330
- max_tokens=1000,
331
- stream=True,
332
- ))
 
 
 
 
333
  for chunk in stream:
334
  delta = chunk.choices[0].delta.content or ""
335
  if delta:
@@ -358,6 +433,7 @@ def _split_tokens(text: str, chunk_size: int = 4):
358
 
359
  # ─── CLARIFICATION PARSER ─────────────────────────────────────────────────────
360
 
 
361
  def extract_clarification_options(reply: str) -> tuple[str, list[str]]:
362
  """Strip embedded JSON clarification options from the reply text."""
363
  suggested_actions: list[str] = []
@@ -373,12 +449,13 @@ def extract_clarification_options(reply: str) -> tuple[str, list[str]]:
373
  suggested_actions = data.get("clarification_options", [])
374
  reply = reply.replace(json_match.group(0), "").strip()
375
  except json.JSONDecodeError:
376
- pass
377
  return reply, suggested_actions
378
 
379
 
380
  # ─── PUBLIC ENTRY POINT ───────────────────────────────────────────────────────
381
 
 
382
  @observe(name="process-chat")
383
  async def process_chat_message(
384
  user_query: str,
@@ -397,13 +474,21 @@ async def process_chat_message(
397
 
398
  if classification == "OUT_OF_SCOPE":
399
  get_client().update_current_span(output=_REPLY_OUT_OF_SCOPE)
400
- return {"reply": _REPLY_OUT_OF_SCOPE, "path_used": "OUT_OF_SCOPE",
401
- "tools_called": [], "suggested_actions": []}
 
 
 
 
402
 
403
  if classification == "GREETING":
404
  get_client().update_current_span(output=_REPLY_GREETING)
405
- return {"reply": _REPLY_GREETING, "path_used": "GREETING",
406
- "tools_called": [], "suggested_actions": []}
 
 
 
 
407
 
408
  # STEP 2: RAG retrieval for knowledge-base paths
409
  rag_chunks: list[str] = []
@@ -413,17 +498,20 @@ async def process_chat_message(
413
  # STEP 3: Build message context
414
  system_prompt = build_system_prompt(classification) # Fix #3
415
  messages: list[dict] = [{"role": "system", "content": system_prompt}]
416
- messages.extend(get_context_window(session_id)) # Fix #6 (trimmed history)
417
 
418
  user_msg = f"User Context Filters: {json.dumps(session_context)}\nUser Query: {user_query}"
419
  messages.append({"role": "user", "content": user_msg})
420
  append_message(session_id, {"role": "user", "content": user_msg})
421
 
422
  if rag_chunks:
423
- messages.append({
424
- "role": "system",
425
- "content": "Retrieved Methodology Context:\n" + "\n---\n".join(rag_chunks),
426
- })
 
 
 
427
 
428
  # STEP 4: Select tools
429
  active_tools = None
@@ -436,7 +524,11 @@ async def process_chat_message(
436
  tool_results: list[dict] = []
437
  final_reply = await run_agent_loop(messages, active_tools, tool_results)
438
 
439
- if not final_reply or final_reply.strip().startswith('{"name":') or final_reply.strip().startswith("<function="):
 
 
 
 
440
  final_reply = _REPLY_FALLBACK
441
 
442
  # STEP 6: Store assistant reply in session
@@ -465,22 +557,31 @@ async def stream_chat_message(
465
  SSE streaming handler.
466
  Yields: {"type": "status"|"tool_call"|"token"|"done", ...}
467
  """
468
- with propagate_attributes(session_id=session_id, tags=["real-estate-chat", "stream"]):
469
-
 
470
  # STEP 1: Route
471
  classification = await classify_query(user_query)
472
  yield {"type": "status", "classification": classification}
473
 
474
  if classification == "OUT_OF_SCOPE":
475
  yield {"type": "token", "token": _REPLY_OUT_OF_SCOPE}
476
- yield {"type": "done", "tools_called": [], "suggested_actions": [],
477
- "path_used": "OUT_OF_SCOPE"}
 
 
 
 
478
  return
479
 
480
  if classification == "GREETING":
481
  yield {"type": "token", "token": _REPLY_GREETING}
482
- yield {"type": "done", "tools_called": [], "suggested_actions": [],
483
- "path_used": "GREETING"}
 
 
 
 
484
  return
485
 
486
  # STEP 2: RAG
@@ -496,10 +597,13 @@ async def stream_chat_message(
496
  messages.append({"role": "user", "content": user_msg})
497
  append_message(session_id, {"role": "user", "content": user_msg})
498
  if rag_chunks:
499
- messages.append({
500
- "role": "system",
501
- "content": "Retrieved Methodology Context:\n" + "\n---\n".join(rag_chunks),
502
- })
 
 
 
503
 
504
  # STEP 4: Tools
505
  active_tools = None
@@ -512,7 +616,9 @@ async def stream_chat_message(
512
  tool_results: list[dict] = []
513
  full_reply_parts: list[str] = []
514
 
515
- async for event in run_agent_loop_streaming(messages, active_tools, tool_results):
 
 
516
  if event["type"] == "token":
517
  full_reply_parts.append(event["token"])
518
  yield event
 
48
 
49
  logger = setup_logger(__name__)
50
 
51
+ MAX_TOOL_ROUNDS = 4 # Maximum tool-call rounds before forcing synthesis
52
 
53
  # ─── STATIC RESPONSES ─────────────────────────────────────────────────────────
54
 
 
69
 
70
  # ─── ROUTER ───────────────────────────────────────────────────────────────────
71
 
72
+
73
+ # Valid classification labels - used to validate router output
74
+ _VALID_CLASSIFICATIONS = frozenset({
75
+ "OUT_OF_SCOPE", "PATH_A", "PATH_B", "BOTH", "GREETING", "COMMERCIAL_HANDOFF"
76
+ })
77
+
78
+
79
+ def _extract_json_from_text(text: str) -> dict:
80
+ """
81
+ Extract a JSON object from raw model output.
82
+
83
+ Handles two patterns:
84
+ 1. Thinking models (qwen): output wrapped in <think>...</think> before JSON.
85
+ 2. Standard: clean JSON object in the content.
86
+
87
+ Falls back to regex extraction if json.loads fails on the full text.
88
+ """
89
+ if not text:
90
+ return {}
91
+
92
+ # Strip <think>...</think> block if present (Qwen thinking models)
93
+ clean = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
94
+
95
+ # Try direct parse
96
+ try:
97
+ return json.loads(clean)
98
+ except json.JSONDecodeError:
99
+ pass
100
+
101
+ # Regex: find first {...} block
102
+ match = re.search(r"\{[^{}]*\}", clean, re.DOTALL)
103
+ if match:
104
+ try:
105
+ return json.loads(match.group(0))
106
+ except json.JSONDecodeError:
107
+ pass
108
+
109
+ return {}
110
+
111
+
112
  async def classify_query(user_query: str) -> str:
113
  """
114
+ Classify the user query into one of 6 route tags using a fast router model.
115
+
116
+ NOTE: response_format={"type": "json_object"} is intentionally NOT used.
117
+ Qwen thinking models (qwen/qwen3.6-27b) reject JSON mode with a 400
118
+ json_validate_failed error. We ask for JSON via the prompt instead and
119
+ extract it with _extract_json_from_text() which handles <think> blocks
120
+ and falls back to regex extraction.
121
  """
122
  messages = [
123
  {"role": "system", "content": ROUTER_PROMPT},
124
  {"role": "user", "content": user_query},
125
  ]
 
126
  for model in [GROQ_ROUTE_MODEL, GROQ_FALLBACK_ROUTE_MODEL]:
127
  try:
128
  res = await groq_call(
129
  model=model,
130
  messages=messages,
131
+ max_tokens=500, # Allow thinking models to finish <think> block
132
  temperature=0.0,
133
+ # No response_format - unsupported by Qwen thinking models
134
  )
135
  raw = res.choices[0].message.content or "{}"
136
+ data = _extract_json_from_text(raw)
137
+ classification = data.get("classification", "")
138
+
139
+ if classification not in _VALID_CLASSIFICATIONS:
140
+ logger.warning(
141
+ f"[router] {model} returned invalid classification '{classification}', defaulting PATH_A"
142
+ )
143
+ classification = "PATH_A"
144
+
145
+ logger.info(
146
+ f"[router] {model} -> {classification}: {data.get('reason', '')}"
147
+ )
148
  return classification
149
+
150
  except Exception as exc:
151
  if is_structural_error(exc):
152
  logger.warning(f"[router] structural error on {model}: {exc}")
153
  else:
154
  logger.warning(f"[router] {model} failed, trying fallback: {exc}")
155
+
156
+ return "PATH_A" # Safe default
157
 
158
 
159
  # ─── AGENTIC TOOL LOOP ────────────────────────────────────────────────────────
160
 
161
+
162
  async def run_agent_loop(
163
  messages: list[dict],
164
  active_tools: list | None,
 
199
  # or schema mismatch β€” recover by executing the tool directly.
200
  fn, fn_args = parse_failed_generation(failed_gen)
201
  if fn and isinstance(fn_args, dict):
202
+ logger.info(
203
+ f"[agent_loop] recovering tool={fn} from failed_generation"
204
+ )
205
  tool_result = await execute_tool_by_name(fn, fn_args)
206
  tool_results.append({"tool": fn, "args": fn_args})
207
+ messages.append(
208
+ {
209
+ "tool_call_id": f"recov_{int(time.time())}",
210
+ "role": "tool",
211
+ "name": fn,
212
+ "content": compress_tool_output(fn, tool_result),
213
+ }
214
+ )
215
  # After injecting result, break to top of outer loop to retry
216
  break
217
  logger.warning(f"[agent_loop] {model} error round={round_num}: {exc}")
 
230
  if finish_reason == "tool_calls":
231
  messages.append(normalize_assistant_message(response_message)) # Fix #5
232
  for tc in response_message.tool_calls:
233
+ fn = tc.function.name
234
  args = parse_tool_args(tc.function.arguments)
235
  logger.info(f"[agent_loop] executing tool={fn} args={args}")
236
 
237
  result = await execute_tool_by_name(fn, args)
238
  tool_results.append({"tool": fn, "args": args})
239
+ messages.append(
240
+ {
241
+ "tool_call_id": tc.id,
242
+ "role": "tool",
243
+ "name": fn,
244
+ "content": compress_tool_output(fn, result),
245
+ }
246
+ )
247
  # Continue loop β€” model may want more tools or synthesis next round
248
 
249
  # ── Direct text reply β€” done ──────────────────────────────────────────
 
254
  logger.warning("[agent_loop] max rounds reached, forcing synthesis")
255
  for model in [GROQ_SYNTHESIS_MODEL, GROQ_FALLBACK_SYNTHESIS_MODEL]:
256
  try:
257
+ synth_messages = list(messages) + [
258
+ {
259
+ "role": "system",
260
+ "content": (
261
+ "Synthesize the real estate data gathered above into a clear, "
262
+ "concise Markdown reply for the user. Do NOT call any more tools."
263
+ ),
264
+ }
265
+ ]
266
  final_res = await groq_call(
267
  model=model,
268
  messages=synth_messages,
 
278
  fn, fn_args = parse_failed_generation(failed_gen)
279
  if fn == "generate_data_export" and isinstance(fn_args, dict):
280
  from services.appwrite_service import upload_document_to_appwrite
281
+
282
  content = fn_args.get("content", "")
283
  upload_res = await upload_document_to_appwrite(content, "md")
284
  dl_url = upload_res.get("download_url", "")
 
292
 
293
  # ─── STREAMING AGENT LOOP (for SSE) ───────────────────────────────────────────
294
 
295
+
296
  async def run_agent_loop_streaming(
297
  messages: list[dict],
298
  active_tools: list | None,
 
334
  if fn and isinstance(fn_args, dict):
335
  tool_result = await execute_tool_by_name(fn, fn_args)
336
  tool_results.append({"tool": fn, "args": fn_args})
337
+ messages.append(
338
+ {
339
+ "tool_call_id": f"recov_{int(time.time())}",
340
+ "role": "tool",
341
+ "name": fn,
342
+ "content": compress_tool_output(fn, tool_result),
343
+ }
344
+ )
345
  yield {"type": "tool_call", "tool": fn, "args": fn_args}
346
  break
347
  logger.warning(f"[stream_loop] {model} error round={round_num}: {exc}")
 
353
  if has_tool_calls:
354
  messages.append(normalize_assistant_message(response_message))
355
  for tc in response_message.tool_calls:
356
+ fn = tc.function.name
357
  args = parse_tool_args(tc.function.arguments)
358
  yield {"type": "tool_call", "tool": fn, "args": args}
359
  result = await execute_tool_by_name(fn, args)
360
  tool_results.append({"tool": fn, "args": args})
361
+ messages.append(
362
+ {
363
+ "tool_call_id": tc.id,
364
+ "role": "tool",
365
+ "name": fn,
366
+ "content": compress_tool_output(fn, result),
367
+ }
368
+ )
369
  else:
370
  # Model returned a direct text reply β€” stream it token by token
371
  if response_message.content:
 
377
  # Forced synthesis with streaming
378
  for model in [GROQ_SYNTHESIS_MODEL, GROQ_FALLBACK_SYNTHESIS_MODEL]:
379
  try:
380
+ synth_messages = list(messages) + [
381
+ {
382
+ "role": "system",
383
+ "content": "Synthesize the real estate data gathered above into a clear Markdown reply. Do NOT call any more tools.",
384
+ }
385
+ ]
386
 
387
  def _stream_call():
388
  return groq_client.chat.completions.create(
 
394
  )
395
 
396
  from services.groq_client import groq_client as _gc
397
+
398
+ stream = await loop.run_in_executor(
399
+ None,
400
+ lambda: _gc.chat.completions.create(
401
+ model=model,
402
+ messages=synth_messages,
403
+ temperature=0.2,
404
+ max_tokens=1000,
405
+ stream=True,
406
+ ),
407
+ )
408
  for chunk in stream:
409
  delta = chunk.choices[0].delta.content or ""
410
  if delta:
 
433
 
434
  # ─── CLARIFICATION PARSER ─────────────────────────────────────────────────────
435
 
436
+
437
  def extract_clarification_options(reply: str) -> tuple[str, list[str]]:
438
  """Strip embedded JSON clarification options from the reply text."""
439
  suggested_actions: list[str] = []
 
449
  suggested_actions = data.get("clarification_options", [])
450
  reply = reply.replace(json_match.group(0), "").strip()
451
  except json.JSONDecodeError:
452
+ logger.error(f"Failed to parse clarification options from reply: {reply}")
453
  return reply, suggested_actions
454
 
455
 
456
  # ─── PUBLIC ENTRY POINT ───────────────────────────────────────────────────────
457
 
458
+
459
  @observe(name="process-chat")
460
  async def process_chat_message(
461
  user_query: str,
 
474
 
475
  if classification == "OUT_OF_SCOPE":
476
  get_client().update_current_span(output=_REPLY_OUT_OF_SCOPE)
477
+ return {
478
+ "reply": _REPLY_OUT_OF_SCOPE,
479
+ "path_used": "OUT_OF_SCOPE",
480
+ "tools_called": [],
481
+ "suggested_actions": [],
482
+ }
483
 
484
  if classification == "GREETING":
485
  get_client().update_current_span(output=_REPLY_GREETING)
486
+ return {
487
+ "reply": _REPLY_GREETING,
488
+ "path_used": "GREETING",
489
+ "tools_called": [],
490
+ "suggested_actions": [],
491
+ }
492
 
493
  # STEP 2: RAG retrieval for knowledge-base paths
494
  rag_chunks: list[str] = []
 
498
  # STEP 3: Build message context
499
  system_prompt = build_system_prompt(classification) # Fix #3
500
  messages: list[dict] = [{"role": "system", "content": system_prompt}]
501
+ messages.extend(get_context_window(session_id)) # Fix #6 (trimmed history)
502
 
503
  user_msg = f"User Context Filters: {json.dumps(session_context)}\nUser Query: {user_query}"
504
  messages.append({"role": "user", "content": user_msg})
505
  append_message(session_id, {"role": "user", "content": user_msg})
506
 
507
  if rag_chunks:
508
+ messages.append(
509
+ {
510
+ "role": "system",
511
+ "content": "Retrieved Methodology Context:\n"
512
+ + "\n---\n".join(rag_chunks),
513
+ }
514
+ )
515
 
516
  # STEP 4: Select tools
517
  active_tools = None
 
524
  tool_results: list[dict] = []
525
  final_reply = await run_agent_loop(messages, active_tools, tool_results)
526
 
527
+ if (
528
+ not final_reply
529
+ or final_reply.strip().startswith('{"name":')
530
+ or final_reply.strip().startswith("<function=")
531
+ ):
532
  final_reply = _REPLY_FALLBACK
533
 
534
  # STEP 6: Store assistant reply in session
 
557
  SSE streaming handler.
558
  Yields: {"type": "status"|"tool_call"|"token"|"done", ...}
559
  """
560
+ with propagate_attributes(
561
+ session_id=session_id, tags=["real-estate-chat", "stream"]
562
+ ):
563
  # STEP 1: Route
564
  classification = await classify_query(user_query)
565
  yield {"type": "status", "classification": classification}
566
 
567
  if classification == "OUT_OF_SCOPE":
568
  yield {"type": "token", "token": _REPLY_OUT_OF_SCOPE}
569
+ yield {
570
+ "type": "done",
571
+ "tools_called": [],
572
+ "suggested_actions": [],
573
+ "path_used": "OUT_OF_SCOPE",
574
+ }
575
  return
576
 
577
  if classification == "GREETING":
578
  yield {"type": "token", "token": _REPLY_GREETING}
579
+ yield {
580
+ "type": "done",
581
+ "tools_called": [],
582
+ "suggested_actions": [],
583
+ "path_used": "GREETING",
584
+ }
585
  return
586
 
587
  # STEP 2: RAG
 
597
  messages.append({"role": "user", "content": user_msg})
598
  append_message(session_id, {"role": "user", "content": user_msg})
599
  if rag_chunks:
600
+ messages.append(
601
+ {
602
+ "role": "system",
603
+ "content": "Retrieved Methodology Context:\n"
604
+ + "\n---\n".join(rag_chunks),
605
+ }
606
+ )
607
 
608
  # STEP 4: Tools
609
  active_tools = None
 
616
  tool_results: list[dict] = []
617
  full_reply_parts: list[str] = []
618
 
619
+ async for event in run_agent_loop_streaming(
620
+ messages, active_tools, tool_results
621
+ ):
622
  if event["type"] == "token":
623
  full_reply_parts.append(event["token"])
624
  yield event
services/prompts.py CHANGED
@@ -23,7 +23,10 @@ Analyze the user query and classify it into EXACTLY ONE of six classifications:
23
  6. "COMMERCIAL_HANDOFF": Query asks about getting started, hiring Joule Dynamics, custom builds, custom dashboards, pricing for software, or requests tracking for their own specific portfolio outside the demo scope.
24
 
25
  Respond ONLY with valid JSON matching this schema:
26
- {"classification": "OUT_OF_SCOPE" | "PATH_A" | "PATH_B" | "BOTH" | "GREETING" | "COMMERCIAL_HANDOFF", "reason": "1-sentence justification"}
 
 
 
27
  """
28
 
29
  # ─── SYNTHESIS PROMPT β€” CORE (always included) ────────────────────────────────
@@ -103,6 +106,7 @@ When a user asks about custom builds, deploying this system for their business,
103
 
104
  # ─── COMPOSER ─────────────────────────────────────────────────────────────────
105
 
 
106
  def build_system_prompt(classification: str) -> str:
107
  """
108
  Compose the system prompt from core + relevant extension blocks only.
 
23
  6. "COMMERCIAL_HANDOFF": Query asks about getting started, hiring Joule Dynamics, custom builds, custom dashboards, pricing for software, or requests tracking for their own specific portfolio outside the demo scope.
24
 
25
  Respond ONLY with valid JSON matching this schema:
26
+ {
27
+ "classification": "OUT_OF_SCOPE" | "PATH_A" | "PATH_B" | "BOTH" | "GREETING" | "COMMERCIAL_HANDOFF",
28
+ "reason": "1-sentence justification"
29
+ }
30
  """
31
 
32
  # ─── SYNTHESIS PROMPT β€” CORE (always included) ────────────────────────────────
 
106
 
107
  # ─── COMPOSER ─────────────────────────────────────────────────────────────────
108
 
109
+
110
  def build_system_prompt(classification: str) -> str:
111
  """
112
  Compose the system prompt from core + relevant extension blocks only.