synapse-orch-ai 1.5.5 → 1.5.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/backend/core/llm_providers.py +78 -41
  2. package/backend/core/orchestration/context.py +23 -10
  3. package/backend/core/orchestration/logger.py +9 -1
  4. package/backend/core/react_engine.py +184 -109
  5. package/backend/core/routes/chat.py +9 -1
  6. package/backend/core/tools.py +109 -53
  7. package/frontend-build/.next/BUILD_ID +1 -1
  8. package/frontend-build/.next/build-manifest.json +3 -3
  9. package/frontend-build/.next/prerender-manifest.json +3 -3
  10. package/frontend-build/.next/server/app/_global-error.html +1 -1
  11. package/frontend-build/.next/server/app/_global-error.rsc +1 -1
  12. package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  13. package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  14. package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  15. package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  16. package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  17. package/frontend-build/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/frontend-build/.next/server/app/_not-found.html +1 -1
  19. package/frontend-build/.next/server/app/_not-found.rsc +2 -2
  20. package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  21. package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  22. package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  23. package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  24. package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  25. package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  26. package/frontend-build/.next/server/app/index.html +1 -1
  27. package/frontend-build/.next/server/app/index.rsc +3 -3
  28. package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  29. package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +3 -3
  30. package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
  31. package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +2 -2
  32. package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  33. package/frontend-build/.next/server/app/login/page_client-reference-manifest.js +1 -1
  34. package/frontend-build/.next/server/app/login.html +1 -1
  35. package/frontend-build/.next/server/app/login.rsc +2 -2
  36. package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +2 -2
  37. package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
  38. package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +2 -2
  39. package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +2 -2
  40. package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  41. package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
  42. package/frontend-build/.next/server/app/page_client-reference-manifest.js +1 -1
  43. package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
  44. package/frontend-build/.next/server/chunks/ssr/src_app_page_tsx_0ss2.w7._.js +1 -1
  45. package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
  46. package/frontend-build/.next/server/middleware-manifest.json +5 -5
  47. package/frontend-build/.next/server/pages/404.html +1 -1
  48. package/frontend-build/.next/server/pages/500.html +1 -1
  49. package/frontend-build/.next/server/server-reference-manifest.js +1 -1
  50. package/frontend-build/.next/server/server-reference-manifest.json +1 -1
  51. package/frontend-build/.next/static/chunks/0htooj7jtj_tj.js +1 -0
  52. package/frontend-build/.next/static/chunks/0oxr2cvb1sal0.css +1 -0
  53. package/package.json +1 -1
  54. package/frontend-build/.next/static/chunks/0m6b8x86zmjhr.js +0 -1
  55. package/frontend-build/.next/static/chunks/0sifx3jp~fn_h.css +0 -1
  56. /package/frontend-build/.next/static/{wMcHp1vhEa3V0OBQS57Hg → BSH_GDVYozCn9fUMJ5J-6}/_buildManifest.js +0 -0
  57. /package/frontend-build/.next/static/{wMcHp1vhEa3V0OBQS57Hg → BSH_GDVYozCn9fUMJ5J-6}/_clientMiddlewareManifest.js +0 -0
  58. /package/frontend-build/.next/static/{wMcHp1vhEa3V0OBQS57Hg → BSH_GDVYozCn9fUMJ5J-6}/_ssgManifest.js +0 -0
@@ -663,15 +663,11 @@ async def call_openai(model, messages, api_key, tools=None, images=None):
663
663
  # Handle tool_calls response
664
664
  choice = data["choices"][0]
665
665
  msg = choice.get("message", {})
666
+ text = _openai_compat_extract(msg)
666
667
  if msg.get("tool_calls"):
667
- tc = msg["tool_calls"][0]
668
- text = json.dumps({
669
- "tool": tc["function"]["name"],
670
- "arguments": json.loads(tc["function"].get("arguments", "{}"))
671
- })
672
668
  return text, input_tokens, output_tokens
673
669
  print(f"DEBUG: ✅ OpenAI call complete (attempt {attempt})", flush=True)
674
- return msg.get("content", ""), input_tokens, output_tokens
670
+ return text, input_tokens, output_tokens
675
671
  except httpx.TimeoutException:
676
672
  last_error = f"Request timed out ({OPENAI_TIMEOUT}s)"
677
673
  print(f"DEBUG: ⏱️ OpenAI timeout on attempt {attempt}/{MAX_RETRIES}. Retrying in {backoff}s...", flush=True)
@@ -723,22 +719,56 @@ def _convert_tools_for_anthropic(ollama_tools: list[dict] | None) -> list[dict]
723
719
  return tools if tools else None
724
720
 
725
721
 
722
+ def _openai_compat_extract(msg: dict) -> str:
723
+ """Normalize an OpenAI-compatible chat-completion message into the
724
+ `{"tool": ..., "arguments": ...}` shape (or plain text).
725
+
726
+ When the model emits BOTH content text and tool_calls in the same response
727
+ (newer reasoning models, some Claude-compat APIs), the text is preserved as
728
+ a `[REASONING]...[/REASONING]` preamble so the ReAct loop can project it
729
+ into the llm_reasoning event for the UI and orchestration log.
730
+ """
731
+ tool_calls = msg.get("tool_calls") or []
732
+ if tool_calls:
733
+ tc = tool_calls[0]
734
+ call_json = json.dumps({
735
+ "tool": tc["function"]["name"],
736
+ "arguments": json.loads(tc["function"].get("arguments") or "{}"),
737
+ })
738
+ reasoning = (msg.get("content") or "").strip()
739
+ if reasoning:
740
+ return f"[REASONING]\n{reasoning}\n[/REASONING]\n{call_json}"
741
+ return call_json
742
+ return msg.get("content", "") or ""
743
+
744
+
726
745
  def _extract_anthropic_response(response) -> str:
727
- """Extract text or tool call from an Anthropic SDK response.
746
+ """Extract text and/or tool call from an Anthropic SDK response.
728
747
 
729
- Checks for tool_use content blocks first (native tool calling),
730
- then falls back to text blocks.
748
+ When the model emits a text block before a tool_use block (Claude does this
749
+ routinely), preserve the text as a [REASONING]...[/REASONING] preamble so
750
+ the downstream ReAct loop can project it into the llm_reasoning event.
751
+ Without this, native tool calling silently drops the model's pre-call
752
+ reasoning.
731
753
  """
732
754
  if not response.content:
733
755
  return "Error: Empty Anthropic response."
734
756
 
735
- # Check for tool_use blocks first (native tool calling)
757
+ text_parts: list[str] = []
758
+ tool_use_call: dict | None = None
736
759
  for block in response.content:
737
- if block.type == "tool_use":
738
- return json.dumps({"tool": block.name, "arguments": block.input or {}})
760
+ if block.type == "tool_use" and tool_use_call is None:
761
+ tool_use_call = {"tool": block.name, "arguments": block.input or {}}
762
+ elif block.type == "text" and block.text:
763
+ text_parts.append(block.text)
764
+
765
+ if tool_use_call is not None:
766
+ call_json = json.dumps(tool_use_call)
767
+ reasoning = "\n".join(t for t in text_parts if t and t.strip()).strip()
768
+ if reasoning:
769
+ return f"[REASONING]\n{reasoning}\n[/REASONING]\n{call_json}"
770
+ return call_json
739
771
 
740
- # Collect text blocks
741
- text_parts = [block.text for block in response.content if block.type == "text" and block.text]
742
772
  if text_parts:
743
773
  return "\n".join(text_parts)
744
774
 
@@ -943,7 +973,14 @@ def _convert_messages_for_gemini(messages: list[dict], images: list[str] | None
943
973
 
944
974
 
945
975
  def _extract_gemini_response(response) -> str:
946
- """Extract text or function call from a Gemini response."""
976
+ """Extract text and/or function call from a Gemini response.
977
+
978
+ When the model emits BOTH text and a function_call in the same response,
979
+ the text is preserved as a [REASONING]...[/REASONING] preamble so the
980
+ downstream ReAct loop can project it into the llm_reasoning event for the
981
+ chat UI and orchestration log. Without this, native function calling
982
+ silently drops the model's pre-call reasoning.
983
+ """
947
984
  if not response.candidates:
948
985
  return "Error: No response candidates from Gemini."
949
986
 
@@ -956,23 +993,27 @@ def _extract_gemini_response(response) -> str:
956
993
  reason = candidate.finish_reason.name if candidate.finish_reason else "UNKNOWN"
957
994
  return f"Error: Empty Gemini response. Finish Reason: {reason}"
958
995
 
959
- # Check for function calls first (native tool calling)
960
- function_calls = []
996
+ # Walk parts in order, collecting text (reasoning) and function calls.
997
+ text_parts: list[str] = []
998
+ function_calls: list[dict] = []
961
999
  for p in candidate.content.parts:
962
1000
  if p.function_call:
963
1001
  fc = p.function_call
964
1002
  args = dict(fc.args) if fc.args else {}
965
1003
  function_calls.append({"tool": fc.name, "arguments": args})
1004
+ elif p.text:
1005
+ text_parts.append(p.text)
966
1006
 
967
1007
  if function_calls:
968
- # Return the first function call (ReAct loop processes one at a time)
969
1008
  if len(function_calls) > 1:
970
1009
  names = [fc["tool"] for fc in function_calls]
971
1010
  print(f"DEBUG: ⚠️ Gemini returned {len(function_calls)} function calls: {names}. Using first: {names[0]}")
972
- return json.dumps(function_calls[0])
1011
+ call_json = json.dumps(function_calls[0])
1012
+ reasoning = "\n".join(t for t in text_parts if t and t.strip()).strip()
1013
+ if reasoning:
1014
+ return f"[REASONING]\n{reasoning}\n[/REASONING]\n{call_json}"
1015
+ return call_json
973
1016
 
974
- # Collect text parts
975
- text_parts = [p.text for p in candidate.content.parts if p.text]
976
1017
  if text_parts:
977
1018
  return "\n".join(text_parts)
978
1019
 
@@ -1141,15 +1182,11 @@ async def call_grok(model, messages, system, api_key, tools=None, images=None):
1141
1182
  output_tokens = usage.get("completion_tokens", 0)
1142
1183
  choice = data["choices"][0]
1143
1184
  msg = choice.get("message", {})
1185
+ text = _openai_compat_extract(msg)
1144
1186
  if msg.get("tool_calls"):
1145
- tc = msg["tool_calls"][0]
1146
- text = json.dumps({
1147
- "tool": tc["function"]["name"],
1148
- "arguments": json.loads(tc["function"].get("arguments", "{}"))
1149
- })
1150
1187
  return text, input_tokens, output_tokens
1151
1188
  print(f"DEBUG: ✅ Grok call complete (attempt {attempt})", flush=True)
1152
- return msg.get("content", ""), input_tokens, output_tokens
1189
+ return text, input_tokens, output_tokens
1153
1190
  except httpx.TimeoutException:
1154
1191
  last_error = f"Request timed out ({GROK_TIMEOUT}s)"
1155
1192
  print(f"DEBUG: ⏱️ Grok timeout on attempt {attempt}/{MAX_RETRIES}. Retrying in {backoff}s...", flush=True)
@@ -1233,15 +1270,11 @@ async def call_deepseek(model, messages, system, api_key, tools=None, images=Non
1233
1270
  output_tokens = usage.get("completion_tokens", 0)
1234
1271
  choice = data["choices"][0]
1235
1272
  msg = choice.get("message", {})
1273
+ text = _openai_compat_extract(msg)
1236
1274
  if msg.get("tool_calls"):
1237
- tc = msg["tool_calls"][0]
1238
- text = json.dumps({
1239
- "tool": tc["function"]["name"],
1240
- "arguments": json.loads(tc["function"].get("arguments", "{}"))
1241
- })
1242
1275
  return text, input_tokens, output_tokens
1243
1276
  print(f"DEBUG: ✅ DeepSeek call complete (attempt {attempt})", flush=True)
1244
- return msg.get("content", ""), input_tokens, output_tokens
1277
+ return text, input_tokens, output_tokens
1245
1278
  except httpx.TimeoutException:
1246
1279
  last_error = f"Request timed out ({DEEPSEEK_TIMEOUT}s)"
1247
1280
  print(f"DEBUG: ⏱️ DeepSeek timeout on attempt {attempt}/{MAX_RETRIES}. Retrying in {backoff}s...", flush=True)
@@ -1346,15 +1379,11 @@ async def call_v1_compatible(model, messages, system, base_url, api_key, tools=N
1346
1379
  output_tokens = usage.get("completion_tokens", 0)
1347
1380
  choice = data["choices"][0]
1348
1381
  msg = choice.get("message", {})
1382
+ text = _openai_compat_extract(msg)
1349
1383
  if msg.get("tool_calls"):
1350
- tc = msg["tool_calls"][0]
1351
- text = json.dumps({
1352
- "tool": tc["function"]["name"],
1353
- "arguments": json.loads(tc["function"].get("arguments", "{}"))
1354
- })
1355
1384
  return text, input_tokens, output_tokens
1356
1385
  print(f"DEBUG: ✅ V1-compatible call complete (attempt {attempt})", flush=True)
1357
- return msg.get("content", ""), input_tokens, output_tokens
1386
+ return text, input_tokens, output_tokens
1358
1387
  except httpx.TimeoutException:
1359
1388
  last_error = f"Request timed out ({V1_TIMEOUT}s)"
1360
1389
  print(f"DEBUG: ⏱️ V1-compatible timeout on attempt {attempt}/{MAX_RETRIES}. Retrying in {backoff}s...", flush=True)
@@ -1848,13 +1877,21 @@ async def generate_response(
1848
1877
 
1849
1878
  # Check for native tool calls
1850
1879
  if "tool_calls" in msg and msg["tool_calls"]:
1851
- # Convert Ollama native tool call to our internal JSON format
1880
+ # Convert Ollama native tool call to our internal JSON format.
1881
+ # Preserve any text content the model emitted alongside the
1882
+ # call as a [REASONING] preamble (some Ollama models emit
1883
+ # both — qwen3, deepseek-r1 routinely do).
1852
1884
  tc = msg["tool_calls"][0]
1853
1885
  print(f"DEBUG: Native Tool Call received: {tc['function']['name']}", flush=True)
1854
- result_text = json.dumps({
1886
+ call_json = json.dumps({
1855
1887
  "tool": tc["function"]["name"],
1856
1888
  "arguments": tc["function"]["arguments"]
1857
1889
  })
1890
+ reasoning = (msg.get("content") or "").strip()
1891
+ if reasoning:
1892
+ result_text = f"[REASONING]\n{reasoning}\n[/REASONING]\n{call_json}"
1893
+ else:
1894
+ result_text = call_json
1858
1895
  else:
1859
1896
  result_text = msg.get("content", "")
1860
1897
 
@@ -564,7 +564,17 @@ def _format_context_value(
564
564
  label: str,
565
565
  max_chars: int = 5000,
566
566
  ) -> str:
567
- """Format a single shared_state value for inclusion in the prompt."""
567
+ """Format a single shared_state value for inclusion in the prompt.
568
+
569
+ Format:
570
+ ### <key>
571
+ Source: <producer agent or upstream description>
572
+ <value>
573
+
574
+ The bare key (no brackets) is used as the header so the heading cannot be
575
+ visually confused with `{state.<key>}` template placeholders. The Source
576
+ line carries provenance (which agent / step produced this value).
577
+ """
568
578
  # List values from loop/parallel accumulation
569
579
  if isinstance(val, list) and val and isinstance(val[0], dict) and "result" in val[0]:
570
580
  parts = []
@@ -575,16 +585,17 @@ def _format_context_value(
575
585
  if len(result_str) > max_chars:
576
586
  from .summarizer import smart_truncate
577
587
  result_str = smart_truncate(result_str, max_chars)
578
- iter_label = f" (Iteration {iteration})" if iteration else ""
579
- source = f"{agent} → {key}{iter_label}" if agent else f"{key}{iter_label}"
580
- parts.append(f"### [{source}]\n{result_str}")
588
+ iter_suffix = f" (iteration {iteration})" if iteration else ""
589
+ source = f"{agent} → {key}" if agent else f"loop accumulator → {key}"
590
+ parts.append(f"### {key}{iter_suffix}\nSource: {source}\n{result_str}")
581
591
  return "\n\n".join(parts)
582
592
 
583
593
  val_str = str(val)
584
594
  if len(val_str) > max_chars:
585
595
  from .summarizer import smart_truncate
586
596
  val_str = smart_truncate(val_str, max_chars)
587
- return f"### [{label}]\n{val_str}"
597
+ source = label if label and label != key else "shared state"
598
+ return f"### {key}\nSource: {source}\n{val_str}"
588
599
 
589
600
 
590
601
  def build_origin_aware_context(
@@ -713,7 +724,9 @@ def build_origin_aware_context(
713
724
 
714
725
  # Always include user_input unless explicitly in input_keys
715
726
  if "user_input" in run.shared_state and "user_input" not in (step.input_keys or []):
716
- context_parts.append(f"### [user_input]\n{run.shared_state['user_input']}")
727
+ context_parts.append(
728
+ f"### user_input\nSource: initial input\n{run.shared_state['user_input']}"
729
+ )
717
730
 
718
731
  # Human response keys (always inject unless already listed)
719
732
  human_keys = {"human_response"}
@@ -730,7 +743,7 @@ def build_origin_aware_context(
730
743
  if len(val) > 3000:
731
744
  from .summarizer import smart_truncate
732
745
  val = smart_truncate(val, 3000)
733
- context_parts.append(f"### [{hkey}]\n{val}")
746
+ context_parts.append(f"### {hkey}\nSource: human response\n{val}")
734
747
 
735
748
  # Explicitly declared input_keys
736
749
  for key in (step.input_keys or []):
@@ -775,7 +788,9 @@ def build_origin_aware_context(
775
788
  prompt = "\n\n---\n\n".join(sections)
776
789
 
777
790
  # ------------------------------------------------------------------
778
- # System prompt addition — datetime + workflow graph + step position
791
+ # System prompt addition — workflow graph + step position
792
+ # (Date/time is injected separately by build_system_prompt for every
793
+ # agent including orchestration steps — do not duplicate it here.)
779
794
  # ------------------------------------------------------------------
780
795
  # Count completed executions per step for the graph (×N badges).
781
796
  exec_counts: dict[str, int] = {}
@@ -789,8 +804,6 @@ def build_origin_aware_context(
789
804
 
790
805
  graph_md = build_workflow_graph_markdown(engine.orch, step.id, exec_counts)
791
806
  sys_lines = [
792
- datetime_context(),
793
- "",
794
807
  graph_md,
795
808
  "",
796
809
  f"You are currently executing step **\"{step_name}\"** (execution #{transition.execution_number}).",
@@ -181,11 +181,19 @@ class OrchestrationLogger:
181
181
  Preview: {preview}
182
182
  """)
183
183
 
184
+ elif etype == "llm_reasoning":
185
+ reasoning = event.get("reasoning", "")
186
+ turn = event.get("turn", "")
187
+ self._write(f"""
188
+ 💭 REASONING (turn {turn}):
189
+ {self._indent(reasoning)}
190
+ """)
191
+
184
192
  elif etype == "llm_thought":
185
193
  thought = event.get("thought", "")
186
194
  turn = event.get("turn", "")
187
195
  self._write(f"""
188
- 🧠 LLM THOUGHT (turn {turn}):
196
+ 🛠️ ACTION (turn {turn}):
189
197
  {self._indent(thought)}
190
198
  """)
191
199