synapse-orch-ai 1.6.0 → 1.6.2

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 (59) hide show
  1. package/backend/core/builder_tools.py +5 -4
  2. package/backend/core/cache/response_cache.py +18 -0
  3. package/backend/core/mcp_client.py +7 -0
  4. package/backend/core/models_orchestration.py +3 -1
  5. package/backend/core/native_builder/__init__.py +1 -1
  6. package/backend/core/orchestration/context.py +191 -54
  7. package/backend/core/orchestration/steps.py +21 -7
  8. package/backend/core/react_engine.py +7 -0
  9. package/backend/core/routes/settings.py +15 -7
  10. package/backend/core/server.py +9 -0
  11. package/backend/services/google.py +48 -7
  12. package/frontend-build/.next/BUILD_ID +1 -1
  13. package/frontend-build/.next/build-manifest.json +3 -3
  14. package/frontend-build/.next/prerender-manifest.json +3 -3
  15. package/frontend-build/.next/server/app/_global-error.html +1 -1
  16. package/frontend-build/.next/server/app/_global-error.rsc +1 -1
  17. package/frontend-build/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  18. package/frontend-build/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  19. package/frontend-build/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  20. package/frontend-build/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  21. package/frontend-build/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  22. package/frontend-build/.next/server/app/_not-found.html +1 -1
  23. package/frontend-build/.next/server/app/_not-found.rsc +1 -1
  24. package/frontend-build/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  25. package/frontend-build/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  26. package/frontend-build/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  27. package/frontend-build/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  28. package/frontend-build/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  29. package/frontend-build/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  30. package/frontend-build/.next/server/app/index.html +1 -1
  31. package/frontend-build/.next/server/app/index.rsc +1 -1
  32. package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  33. package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +1 -1
  34. package/frontend-build/.next/server/app/index.segments/_head.segment.rsc +1 -1
  35. package/frontend-build/.next/server/app/index.segments/_index.segment.rsc +1 -1
  36. package/frontend-build/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  37. package/frontend-build/.next/server/app/login.html +1 -1
  38. package/frontend-build/.next/server/app/login.rsc +1 -1
  39. package/frontend-build/.next/server/app/login.segments/_full.segment.rsc +1 -1
  40. package/frontend-build/.next/server/app/login.segments/_head.segment.rsc +1 -1
  41. package/frontend-build/.next/server/app/login.segments/_index.segment.rsc +1 -1
  42. package/frontend-build/.next/server/app/login.segments/_tree.segment.rsc +1 -1
  43. package/frontend-build/.next/server/app/login.segments/login/__PAGE__.segment.rsc +1 -1
  44. package/frontend-build/.next/server/app/login.segments/login.segment.rsc +1 -1
  45. package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
  46. package/frontend-build/.next/server/chunks/ssr/_0b~n.nn._.js +3 -3
  47. package/frontend-build/.next/server/chunks/ssr/_0rwng5.._.js +1 -1
  48. package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
  49. package/frontend-build/.next/server/middleware-manifest.json +5 -5
  50. package/frontend-build/.next/server/pages/404.html +1 -1
  51. package/frontend-build/.next/server/pages/500.html +1 -1
  52. package/frontend-build/.next/server/server-reference-manifest.js +1 -1
  53. package/frontend-build/.next/server/server-reference-manifest.json +1 -1
  54. package/frontend-build/.next/static/chunks/{0w5f~4w31j03v.js → 02k3e9vyxajlw.js} +1 -1
  55. package/frontend-build/.next/static/chunks/{139v3bz1_4_sh.js → 0xjv-b-~qzwt1.js} +22 -22
  56. package/package.json +1 -1
  57. /package/frontend-build/.next/static/{cs3gFMScfwOahyeFSLcqL → 65DqAqVD6tjvCkcwNTV2U}/_buildManifest.js +0 -0
  58. /package/frontend-build/.next/static/{cs3gFMScfwOahyeFSLcqL → 65DqAqVD6tjvCkcwNTV2U}/_clientMiddlewareManifest.js +0 -0
  59. /package/frontend-build/.next/static/{cs3gFMScfwOahyeFSLcqL → 65DqAqVD6tjvCkcwNTV2U}/_ssgManifest.js +0 -0
@@ -637,9 +637,10 @@ BUILDER_TOOL_SCHEMAS = [
637
637
  "include_full_history": {
638
638
  "type": "boolean",
639
639
  "description": (
640
- "For agent/llm/tool steps: on re-invocation (evaluator loop-back or retry), "
641
- "include every prior turn's inputs, tools, and output instead of only the last attempt. "
642
- "Useful for feedback loops; increases prompt length."
640
+ "For agent/llm/tool steps: controls revision-history rendering on re-runs. "
641
+ "Default (unset) auto-enables full history whenever this step runs more than once. "
642
+ "Set false to force last-attempt only (smaller prompt); set true is equivalent to default. "
643
+ "Only relevant if you want to explicitly opt OUT of full history."
643
644
  ),
644
645
  },
645
646
  },
@@ -698,7 +699,7 @@ BUILDER_TOOL_SCHEMAS = [
698
699
  "max_turns": {"type": "integer"},
699
700
  "timeout_seconds": {"type": "integer"},
700
701
  "model": {"type": "string"},
701
- "include_full_history": {"type": "boolean", "description": "Include full revision history on re-invocation (agent/llm/tool steps)"},
702
+ "include_full_history": {"type": "boolean", "description": "Opt OUT of full revision history on re-runs (default: auto-on for any re-run). Set false to keep prompts small."},
702
703
  },
703
704
  "required": ["step_id", "name", "type"],
704
705
  },
@@ -93,6 +93,9 @@ def set_exact(
93
93
  # semantic hits limited to nearly-identical prompts.
94
94
 
95
95
  _semantic_collections: dict[str, Any] = {}
96
+ # Track step IDs we've already warned about so the "embedding unavailable" log
97
+ # fires at most once per step per process — avoids spamming during a long run.
98
+ _warned_no_embed_steps: set[str] = set()
96
99
 
97
100
 
98
101
  def _get_memory_store():
@@ -132,6 +135,19 @@ def _embed(text: str) -> Optional[list[float]]:
132
135
  return None
133
136
 
134
137
 
138
+ def _warn_no_embedding(step_id: str) -> None:
139
+ """One-shot log when the embedding provider is unreachable for this step."""
140
+ if step_id in _warned_no_embed_steps:
141
+ return
142
+ _warned_no_embed_steps.add(step_id)
143
+ print(
144
+ f"DEBUG cache: ⚠️ semantic cache disabled for step '{step_id}' — "
145
+ f"embedding provider unavailable. Start Ollama with `nomic-embed-text` "
146
+ f"or set `embedding_model` in Settings. Exact-match cache still works.",
147
+ flush=True,
148
+ )
149
+
150
+
135
151
  def get_semantic(
136
152
  step_id: str,
137
153
  model: str,
@@ -145,6 +161,7 @@ def get_semantic(
145
161
  return None
146
162
  emb = _embed((system or "") + "\n\n" + user_message)
147
163
  if emb is None:
164
+ _warn_no_embedding(step_id)
148
165
  return None
149
166
  try:
150
167
  res = coll.query(query_embeddings=[emb], n_results=1)
@@ -183,6 +200,7 @@ def set_semantic(
183
200
  return
184
201
  emb = _embed((system or "") + "\n\n" + user_message)
185
202
  if emb is None:
203
+ _warn_no_embedding(step_id)
186
204
  return
187
205
  # Reuse the exact-cache key as the Chroma document ID so storage stays unified.
188
206
  key = store.make_key("resp_semantic", model, step_id, user_message)
@@ -441,6 +441,13 @@ class MCPClientManager:
441
441
  if session:
442
442
  self._set_status(name, "connected")
443
443
  await self._auto_register(name) # ← register on startup
444
+ else:
445
+ # Remote servers without a bearer token use OAuth — failure likely means re-auth needed.
446
+ # Stdio and bearer-token servers just go disconnected.
447
+ if server_type == "remote" and not config.get("token"):
448
+ self._set_status(name, "reauth_needed")
449
+ else:
450
+ self._set_status(name, "disconnected")
444
451
  return self.sessions
445
452
 
446
453
  # ── add_server ─────────────────────────────────────────────────────────────
@@ -100,7 +100,9 @@ class StepConfig(BaseModel):
100
100
 
101
101
  # On re-invocation (evaluator feedback or loop), include every prior turn's
102
102
  # inputs/tools/output in the prompt instead of only the last attempt.
103
- include_full_history: bool = False
103
+ # Tri-state: True = always include, False = always last-attempt only,
104
+ # None (default) = auto (full history on any re-run).
105
+ include_full_history: bool | None = None
104
106
 
105
107
  # Graph routing
106
108
  next_step_id: str | None = None # Linear next step / loop "done" path
@@ -24,7 +24,7 @@ __all__ = [
24
24
  # their zero-defaults (`route_map: {}`, `parallel_branches: []`, etc.) so the
25
25
  # engine deserialises cleanly.
26
26
  STEP_TYPE_CHEATSHEET = """\
27
- - **agent**: runs a configured sub-agent with a prompt + its tool set. Required: `agent_id`, `prompt_template`. Optional: `include_full_history` (bool) — on re-invocation show every prior turn's inputs/tools/output instead of only the last attempt; useful for feedback/retry loops. Use for any step that needs multi-turn reasoning or tool use.
27
+ - **agent**: runs a configured sub-agent with a prompt + its tool set. Required: `agent_id`, `prompt_template`. Optional: `include_full_history` (bool) — default behaviour auto-shows full revision history on any re-run; set this to `false` only if you need to keep the prompt small. Use for any step that needs multi-turn reasoning or tool use.
28
28
  - **llm**: single one-shot LLM call, no tools. Required: `prompt_template` (optional `model`). Use for lightweight summarisation, rewriting, or deterministic prose generation.
29
29
  - **tool**: forces a single tool call with no LLM reasoning. Required: `forced_tool` (+ `agent_id` for tool-resolution). Use when the arguments are already in state and just need forwarding.
30
30
  - **evaluator**: pure routing node. Required: `route_map_json` (JSON-encoded `{label: target_step_id}`), `route_descriptions_json` (JSON), `evaluator_prompt`. Output_key stores the bare route label. Use to fork on a classifier decision.
@@ -472,14 +472,22 @@ def _origin_label(origin: dict | None, execution: int) -> str:
472
472
  return otype or "re-invocation"
473
473
 
474
474
 
475
- def _render_full_history(memory: list[dict]) -> str:
476
- """Render every recorded turn as inputs tools output."""
475
+ def _render_full_history(memory: list[dict], skip_last_output: bool = False) -> str:
476
+ """Render every recorded turn as inputs -> tools -> output.
477
+
478
+ `skip_last_output`: when the caller is also rendering the most recent
479
+ turn's output as a standalone YOUR PREVIOUS OUTPUT section, set this to
480
+ True so the history doesn't duplicate that body. The last turn still
481
+ shows its tools/inputs/label; only the Output: block is replaced with
482
+ a pointer back to the standalone section.
483
+ """
477
484
  lines: list[str] = ["## REVISION HISTORY (all prior turns)"]
478
- for entry in memory:
485
+ last_idx = len(memory) - 1
486
+ for idx, entry in enumerate(memory):
479
487
  execution = entry.get("execution", 0)
480
488
  origin = entry.get("origin") or {}
481
489
  label = _origin_label(origin, execution)
482
- lines.append(f"\n### Turn {execution} {label}")
490
+ lines.append(f"\n### Turn {execution} - {label}")
483
491
 
484
492
  # Inputs at the time of this turn
485
493
  inputs = entry.get("inputs") or {}
@@ -509,10 +517,13 @@ def _render_full_history(memory: list[dict]) -> str:
509
517
  # Final output produced on that turn
510
518
  final_out = str(trace.get("final_output") or "")
511
519
  if final_out:
512
- if len(final_out) > 800:
513
- from .summarizer import smart_truncate
514
- final_out = smart_truncate(final_out, 800)
515
- lines.append(f"Output:\n{final_out}")
520
+ if skip_last_output and idx == last_idx:
521
+ lines.append("Output: (see YOUR PREVIOUS OUTPUT section above)")
522
+ else:
523
+ if len(final_out) > 800:
524
+ from .summarizer import smart_truncate
525
+ final_out = smart_truncate(final_out, 800)
526
+ lines.append(f"Output:\n{final_out}")
516
527
 
517
528
  return "\n".join(lines)
518
529
 
@@ -603,18 +614,21 @@ def build_origin_aware_context(
603
614
  run: "OrchestrationRun",
604
615
  engine: "OrchestrationEngine",
605
616
  transition: TransitionContext,
606
- ) -> tuple[str, str]:
617
+ ) -> tuple[str, str, str]:
607
618
  """
608
- Build (prompt, system_prompt_extra) for an agent/tool/llm step using
609
- the structured, origin-aware format.
610
-
611
- Returns two strings:
612
- prompt — the full user-turn message to send to the LLM
613
- system_prompt_extrashort orchestration-awareness block to append to
614
- the agent's system prompt (can be empty string)
619
+ Build (prompt, system_prompt_extra, system_prompt_prefix) for an
620
+ agent/tool/llm step using the structured, origin-aware format.
621
+
622
+ Returns three strings:
623
+ prompt full user-turn message to send to the LLM
624
+ system_prompt_extra short orchestration-awareness block APPENDED to
625
+ the agent's system prompt (can be empty)
626
+ system_prompt_prefix iteration banner PREPENDED to the system prompt
627
+ on re-runs (execution_number > 1); empty otherwise
615
628
  """
616
629
  import re
617
630
  sections: list[str] = []
631
+ is_rerun = transition.execution_number > 1
618
632
 
619
633
  # ------------------------------------------------------------------
620
634
  # Section: ROLE
@@ -659,36 +673,99 @@ def build_origin_aware_context(
659
673
  sections.append("\n".join(role_lines))
660
674
 
661
675
  # ------------------------------------------------------------------
662
- # Section: EVALUATOR FEEDBACK (only on evaluator re-invocation)
676
+ # Section: MAIN GOAL (always present)
677
+ # The user's original request is the root of every step in this
678
+ # workflow. Keep it at the top of every prompt so re-runs, branching,
679
+ # and deep step chains do not lose sight of what we are ultimately
680
+ # solving for. Sourced from run.shared_state["user_input"] which
681
+ # the engine seeds on the first step.
663
682
  # ------------------------------------------------------------------
664
- if transition.origin_type == "evaluator" and (
665
- transition.routing_decision or transition.routing_reasoning
666
- ):
667
- feedback_lines = ["## EVALUATOR FEEDBACK"]
668
- if transition.routing_decision:
669
- feedback_lines.append(f"Decision: \"{transition.routing_decision}\"")
670
- if transition.routing_reasoning:
671
- feedback_lines.append(f"Reason: {transition.routing_reasoning}")
672
-
673
- # Previous output summary
674
- if step.output_key and step.output_key in run.shared_state:
675
- prev_out = str(run.shared_state[step.output_key])
676
- if len(prev_out) > 2000:
677
- from .summarizer import smart_truncate
678
- prev_out = smart_truncate(prev_out, 2000)
679
- feedback_lines.append(f"\n### Your previous output\n{prev_out}")
683
+ main_goal = run.shared_state.get("user_input")
684
+ if main_goal:
685
+ goal_text = str(main_goal).strip()
686
+ if len(goal_text) > 2000:
687
+ from .summarizer import smart_truncate
688
+ goal_text = smart_truncate(goal_text, 2000)
689
+ sections.append(
690
+ "## MAIN GOAL (original user request)\n"
691
+ "Everything this workflow does exists to solve this. "
692
+ "Keep it in mind when deciding what to produce:\n\n"
693
+ f"{goal_text}"
694
+ )
680
695
 
681
- sections.append("\n".join(feedback_lines))
696
+ # ------------------------------------------------------------------
697
+ # Section: WHY YOU ARE RUNNING AGAIN (any re-run, any origin)
698
+ # ------------------------------------------------------------------
699
+ if is_rerun:
700
+ why_lines = [
701
+ "## WHY YOU ARE RUNNING AGAIN",
702
+ f"This is execution #{transition.execution_number} of this step.",
703
+ ]
704
+ if transition.origin_type == "evaluator":
705
+ if transition.routing_decision:
706
+ why_lines.append(f"Evaluator decision: \"{transition.routing_decision}\"")
707
+ if transition.routing_reasoning:
708
+ why_lines.append(f"Evaluator reasoning: {transition.routing_reasoning}")
709
+ why_lines.append(
710
+ "An evaluator reviewed your previous output and routed you back. "
711
+ "See HOW TO PROCEED below."
712
+ )
713
+ elif transition.origin_type == "loop":
714
+ iter_str = (
715
+ f"iteration {transition.loop_iteration} of {transition.loop_total}"
716
+ if transition.loop_iteration and transition.loop_total
717
+ else f"iteration #{transition.execution_number}"
718
+ )
719
+ why_lines.append(
720
+ f"You are inside a loop ({iter_str}). See HOW TO PROCEED below."
721
+ )
722
+ else:
723
+ why_lines.append(
724
+ "The workflow has routed control back to this step. See HOW TO PROCEED below."
725
+ )
726
+ sections.append("\n".join(why_lines))
727
+
728
+ # ------------------------------------------------------------------
729
+ # Section: YOUR PREVIOUS OUTPUT (any re-run)
730
+ # ------------------------------------------------------------------
731
+ if is_rerun and step.output_key and step.output_key in run.shared_state:
732
+ prev_out = str(run.shared_state[step.output_key])
733
+ if len(prev_out) > 2000:
734
+ from .summarizer import smart_truncate
735
+ prev_out = smart_truncate(prev_out, 2000)
736
+ sections.append(
737
+ "## YOUR PREVIOUS OUTPUT\n"
738
+ "This is what you produced last turn. Read it before responding:\n\n"
739
+ f"{prev_out}"
740
+ )
682
741
 
683
742
  # ------------------------------------------------------------------
684
743
  # Section: YOUR PREVIOUS WORK / REVISION HISTORY (on any re-invocation)
685
744
  # ------------------------------------------------------------------
686
- if transition.execution_number > 1:
745
+ if is_rerun:
687
746
  memory = get_execution_memory(run, step.id)
688
747
  if memory:
689
- if step.include_full_history:
690
- sections.append(_render_full_history(memory))
748
+ use_full = (
749
+ step.include_full_history
750
+ if step.include_full_history is not None
751
+ else True
752
+ )
753
+ # Detect whether the standalone YOUR PREVIOUS OUTPUT section was
754
+ # rendered above (same condition used there). If so, avoid
755
+ # duplicating the most recent turn's body in the history.
756
+ previous_output_rendered = bool(
757
+ step.output_key and step.output_key in run.shared_state
758
+ )
759
+ if use_full:
760
+ # With only one prior turn, the history would just restate
761
+ # YOUR PREVIOUS OUTPUT under a turn label. Skip it then.
762
+ if len(memory) > 1:
763
+ sections.append(_render_full_history(
764
+ memory, skip_last_output=previous_output_rendered
765
+ ))
691
766
  else:
767
+ # Last-attempt mode shows only tools/inputs (no output body),
768
+ # so it never duplicates YOUR PREVIOUS OUTPUT.
692
769
  sections.append(_render_last_attempt(memory[-1]))
693
770
 
694
771
  # ------------------------------------------------------------------
@@ -722,11 +799,10 @@ def build_origin_aware_context(
722
799
  # ------------------------------------------------------------------
723
800
  context_parts = []
724
801
 
725
- # Always include user_input unless explicitly in input_keys
726
- if "user_input" in run.shared_state and "user_input" not in (step.input_keys or []):
727
- context_parts.append(
728
- f"### user_input\nSource: initial input\n{run.shared_state['user_input']}"
729
- )
802
+ # NOTE: user_input is rendered at the top under "## MAIN GOAL" so it is
803
+ # not duplicated here. If a step explicitly lists user_input in its
804
+ # input_keys, the explicit-input loop below will skip it for the same
805
+ # reason.
730
806
 
731
807
  # Human response keys (always inject unless already listed)
732
808
  human_keys = {"human_response"}
@@ -745,9 +821,9 @@ def build_origin_aware_context(
745
821
  val = smart_truncate(val, 3000)
746
822
  context_parts.append(f"### {hkey}\nSource: human response\n{val}")
747
823
 
748
- # Explicitly declared input_keys
824
+ # Explicitly declared input_keys (skip user_input — shown under MAIN GOAL)
749
825
  for key in (step.input_keys or []):
750
- if key not in run.shared_state:
826
+ if key not in run.shared_state or key == "user_input":
751
827
  continue
752
828
  val = run.shared_state[key]
753
829
  label = key
@@ -755,14 +831,48 @@ def build_origin_aware_context(
755
831
  (s for s in engine.step_map.values() if s.output_key == key), None
756
832
  )
757
833
  if producer and producer.agent_id and producer.agent_id in engine.agent_names:
758
- label = f"{engine.agent_names[producer.agent_id]} \u2192 {key}"
834
+ label = f"{engine.agent_names[producer.agent_id]} {key}"
759
835
  context_parts.append(_format_context_value(key, val, label))
760
836
 
761
837
  if context_parts:
762
838
  sections.append("## CONTEXT FROM PREVIOUS STEPS\n" + "\n\n".join(context_parts))
763
839
 
764
840
  # ------------------------------------------------------------------
765
- # Section: TASK
841
+ # Section: HOW TO PROCEED (any re-run)
842
+ # Loose framing: agent decides between refine / redo / push back. The
843
+ # only firm requirement is that the new output explain the change (or
844
+ # the deliberate non-change) relative to the previous attempt.
845
+ # ------------------------------------------------------------------
846
+ if is_rerun:
847
+ if transition.origin_type == "loop":
848
+ proceed_block = (
849
+ "## HOW TO PROCEED\n"
850
+ "This is another iteration of a loop. Your previous iteration's output and "
851
+ "any sibling iterations are shown above.\n\n"
852
+ "Use your judgement: produce the next item, refine, or take a different angle. "
853
+ "Just don't silently re-emit what you produced before.\n\n"
854
+ "At the top of your output, include a brief note (1-3 lines) explaining what "
855
+ "this iteration adds or changes relative to the previous one."
856
+ )
857
+ else:
858
+ proceed_block = (
859
+ "## HOW TO PROCEED\n"
860
+ "You are running this step again. Your previous output and the reason for re-running "
861
+ "are shown above. Read them before responding.\n\n"
862
+ "You decide how to respond — any of these are valid:\n"
863
+ " - Refine the previous output (small targeted edits).\n"
864
+ " - Redo it from scratch if it was fundamentally off.\n"
865
+ " - Push back if you believe the feedback is mistaken: explain why and either "
866
+ "defend the previous output or propose a different correction.\n\n"
867
+ "Whichever path you take, include a brief reasoning note at the top of your output "
868
+ "(1-3 lines) that explains:\n"
869
+ " - What changed from your previous output (or why you kept it), and\n"
870
+ " - Why you believe this is the right response to the feedback."
871
+ )
872
+ sections.append(proceed_block)
873
+
874
+ # ------------------------------------------------------------------
875
+ # Section: TASK (first run) / ORIGINAL TASK reference (re-run)
766
876
  # ------------------------------------------------------------------
767
877
  prompt_template = step.prompt_template or run.shared_state.get("user_input", "")
768
878
 
@@ -773,12 +883,17 @@ def build_origin_aware_context(
773
883
 
774
884
  task_text = re.sub(r"\{state\.(\w+)\}", replace_ref, prompt_template)
775
885
 
776
- task_header = "## TASK (REVISION)" if transition.origin_type == "evaluator" else "## TASK"
777
- task_suffix = ""
778
- if transition.origin_type == "evaluator" and transition.routing_reasoning:
779
- task_suffix = "\n\nAddress the evaluator's feedback above in your revised output."
780
- elif transition.origin_type == "human_response":
781
- task_suffix = "\n\nIncorporate the human's input above."
886
+ if is_rerun:
887
+ task_header = "## ORIGINAL TASK (reference only)"
888
+ task_suffix = (
889
+ "\n\nThis is the original task statement, included for reference. "
890
+ "Use it together with YOUR PREVIOUS OUTPUT and HOW TO PROCEED above to decide your response."
891
+ )
892
+ else:
893
+ task_header = "## TASK"
894
+ task_suffix = ""
895
+ if transition.origin_type == "human_response":
896
+ task_suffix = "\n\nIncorporate the human's input above."
782
897
 
783
898
  sections.append(f"{task_header}\n{task_text}{task_suffix}")
784
899
 
@@ -799,7 +914,7 @@ def build_origin_aware_context(
799
914
  if sid:
800
915
  exec_counts[sid] = exec_counts.get(sid, 0) + 1
801
916
  # Use execution_number for the active step (counts this in-flight run).
802
- if transition.execution_number > 1:
917
+ if is_rerun:
803
918
  exec_counts[step.id] = transition.execution_number
804
919
 
805
920
  graph_md = build_workflow_graph_markdown(engine.orch, step.id, exec_counts)
@@ -816,4 +931,26 @@ def build_origin_aware_context(
816
931
  )
817
932
  system_prompt_extra = "\n".join(sys_lines)
818
933
 
819
- return prompt, system_prompt_extra
934
+ # ------------------------------------------------------------------
935
+ # System prompt PREFIX: iteration banner (re-runs only).
936
+ # Prepended to the system prompt so a long agent role description
937
+ # cannot drown out the iteration signal.
938
+ # ------------------------------------------------------------------
939
+ system_prompt_prefix = ""
940
+ if is_rerun:
941
+ why_short = ""
942
+ if transition.origin_type == "evaluator" and transition.routing_decision:
943
+ why_short = f" (evaluator routed back: \"{transition.routing_decision}\")"
944
+ elif transition.origin_type == "loop" and transition.loop_iteration:
945
+ why_short = f" (loop iteration {transition.loop_iteration})"
946
+ system_prompt_prefix = (
947
+ "ITERATION CONTEXT - READ BEFORE RESPONDING\n"
948
+ f"You are on execution #{transition.execution_number} of step "
949
+ f"\"{step_name}\"{why_short}. This is NOT your first attempt.\n"
950
+ "In the user message below, read YOUR PREVIOUS OUTPUT, WHY YOU ARE RUNNING AGAIN, "
951
+ "and HOW TO PROCEED. Then decide how to respond — refine, redo, or push back on the "
952
+ "feedback if you think it is mistaken. Whichever you choose, start your output with a "
953
+ "brief reasoning note explaining what changed (or why you kept the previous output)."
954
+ )
955
+
956
+ return prompt, system_prompt_extra, system_prompt_prefix
@@ -52,13 +52,15 @@ class AgentStepExecutor:
52
52
  if transition is None:
53
53
  from .context import TransitionContext
54
54
  transition = TransitionContext(origin_type="entry", execution_number=1)
55
- prompt, system_prompt_extra = build_origin_aware_context(
55
+ prompt, system_prompt_extra, system_prompt_prefix = build_origin_aware_context(
56
56
  step, run, engine, transition
57
57
  )
58
58
  inputs_snapshot = snapshot_inputs(step, run, engine)
59
59
 
60
60
  # Emit prompt for the orchestration logger (filtered out before SSE)
61
- yield {"type": "_log_prompt", "orch_step_id": step.id, "prompt": prompt, "system_prompt_extra": system_prompt_extra}
61
+ yield {"type": "_log_prompt", "orch_step_id": step.id, "prompt": prompt,
62
+ "system_prompt_extra": system_prompt_extra,
63
+ "system_prompt_prefix": system_prompt_prefix}
62
64
 
63
65
  # ── Orchestrator-as-agent: delegate to a nested OrchestrationEngine ──
64
66
  target_agent = next(
@@ -99,6 +101,7 @@ class AgentStepExecutor:
99
101
  source="orchestration",
100
102
  run_id=run.run_id,
101
103
  system_prompt_extra=system_prompt_extra,
104
+ system_prompt_prefix=system_prompt_prefix,
102
105
  model_override=step.model,
103
106
  ):
104
107
  execution_events.append(event)
@@ -271,10 +274,12 @@ class ToolStepExecutor:
271
274
  transition = getattr(engine, "current_transition", None)
272
275
  if transition is None:
273
276
  transition = TransitionContext(origin_type="entry", execution_number=1)
274
- prompt, system_prompt_extra = build_origin_aware_context(step, run, engine, transition)
277
+ prompt, system_prompt_extra, system_prompt_prefix = build_origin_aware_context(step, run, engine, transition)
275
278
  inputs_snapshot = snapshot_inputs(step, run, engine)
276
279
 
277
- yield {"type": "_log_prompt", "orch_step_id": step.id, "prompt": prompt, "system_prompt_extra": system_prompt_extra}
280
+ yield {"type": "_log_prompt", "orch_step_id": step.id, "prompt": prompt,
281
+ "system_prompt_extra": system_prompt_extra,
282
+ "system_prompt_prefix": system_prompt_prefix}
278
283
 
279
284
  # Model resolution — same pattern as EvaluatorStepExecutor and LLMStepExecutor
280
285
  settings = load_settings()
@@ -325,9 +330,12 @@ class ToolStepExecutor:
325
330
 
326
331
  print(f"DEBUG TOOL STEP: turn {turn + 1}/{max_turns} model={model} tool={tool_name}", flush=True)
327
332
  try:
333
+ tool_sys_prompt = "You are a tool-calling assistant. Output ONLY valid JSON."
334
+ if system_prompt_prefix:
335
+ tool_sys_prompt = system_prompt_prefix + "\n\n" + tool_sys_prompt
328
336
  response = await llm_generate(
329
337
  prompt_msg=turn_prompt,
330
- sys_prompt="You are a tool-calling assistant. Output ONLY valid JSON.",
338
+ sys_prompt=tool_sys_prompt,
331
339
  mode=mode,
332
340
  current_model=model,
333
341
  current_settings=settings,
@@ -1178,10 +1186,12 @@ class LLMStepExecutor:
1178
1186
  if transition is None:
1179
1187
  from .context import TransitionContext
1180
1188
  transition = TransitionContext(origin_type="entry", execution_number=1)
1181
- prompt, system_prompt_extra = build_origin_aware_context(step, run, engine, transition)
1189
+ prompt, system_prompt_extra, system_prompt_prefix = build_origin_aware_context(step, run, engine, transition)
1182
1190
  inputs_snapshot = snapshot_inputs(step, run, engine)
1183
1191
 
1184
- yield {"type": "_log_prompt", "orch_step_id": step.id, "prompt": prompt, "system_prompt_extra": system_prompt_extra}
1192
+ yield {"type": "_log_prompt", "orch_step_id": step.id, "prompt": prompt,
1193
+ "system_prompt_extra": system_prompt_extra,
1194
+ "system_prompt_prefix": system_prompt_prefix}
1185
1195
  yield {
1186
1196
  "type": "thinking",
1187
1197
  "orch_step_id": step.id,
@@ -1196,7 +1206,11 @@ class LLMStepExecutor:
1196
1206
  mode = detect_mode_from_model(model)
1197
1207
 
1198
1208
  # system_prompt_extra already contains datetime + workflow graph + position.
1209
+ # system_prompt_prefix is the iteration banner (re-runs only); prepend it
1210
+ # so it appears at the top of the system prompt.
1199
1211
  sys_prompt = f"You are a helpful assistant. Be concise and accurate.\n\n{system_prompt_extra}"
1212
+ if system_prompt_prefix:
1213
+ sys_prompt = system_prompt_prefix + "\n\n" + sys_prompt
1200
1214
 
1201
1215
  try:
1202
1216
  response = await llm_generate(
@@ -512,6 +512,7 @@ async def run_agent_step(
512
512
  run_id: str | None = None,
513
513
  images: list[str] | None = None,
514
514
  system_prompt_extra: str | None = None,
515
+ system_prompt_prefix: str | None = None,
515
516
  # ── optional extension params (used by builder wrapper) ───────────────────
516
517
  agent_override: dict | None = None, # skip _resolve_agent_by_id
517
518
  tools_override: list | None = None, # skip aggregate_all_tools; list of OpenAI-format tool dicts
@@ -637,6 +638,10 @@ async def run_agent_step(
637
638
  # Inject orchestration-awareness block when called from an orchestration step
638
639
  if system_prompt_extra:
639
640
  system_prompt_text = system_prompt_text + "\n\n" + system_prompt_extra
641
+ # Iteration banner: prepended so it can't be drowned out by a long agent
642
+ # system prompt. Only set on re-runs (execution_number > 1).
643
+ if system_prompt_prefix:
644
+ system_prompt_text = system_prompt_prefix + "\n\n" + system_prompt_text
640
645
 
641
646
  async def generate_response(prompt_msg, sys_prompt, tools=None, history_messages=None, memory_context_text="", images_for_turn=None, tool_name_for_log=None):
642
647
  return await llm_generate_response(
@@ -719,6 +724,8 @@ async def run_agent_step(
719
724
  # step context, shared state, or turn-budget instructions.
720
725
  if system_prompt_extra:
721
726
  active_sys_prompt = active_sys_prompt + "\n\n" + system_prompt_extra
727
+ if system_prompt_prefix:
728
+ active_sys_prompt = system_prompt_prefix + "\n\n" + active_sys_prompt
722
729
 
723
730
  # Determine prompt
724
731
  if turn == 0:
@@ -307,10 +307,6 @@ async def get_config():
307
307
  # Mask: show only last 4 chars, e.g. ****h453
308
308
  masked_client_id = ("****" + client_id_full[-8:]) if len(client_id_full) > 8 else "****"
309
309
 
310
- # Connected only if both the main token and the workspace-mcp token exist
311
- mcp_token_file = os.path.join(DATA_DIR, "google-credentials", "token.json")
312
- is_connected = os.path.exists(TOKEN_FILE) and os.path.exists(mcp_token_file)
313
-
314
310
  # Read user email from token.json if available
315
311
  user_email = None
316
312
  if has_token:
@@ -330,6 +326,20 @@ async def get_config():
330
326
  except Exception:
331
327
  pass
332
328
 
329
+ # Real connectivity check: token must be valid (or refreshable) AND
330
+ # workspace-mcp must have a token file it can read. get_google_credentials()
331
+ # validates, auto-refreshes, and syncs to the MCP dir on success.
332
+ is_connected = False
333
+ try:
334
+ from services.google import get_google_credentials
335
+ if get_google_credentials() is not None:
336
+ mcp_dir = os.path.join(DATA_DIR, "google-credentials")
337
+ per_user = user_email and os.path.exists(os.path.join(mcp_dir, f"{user_email}.json"))
338
+ generic = os.path.exists(os.path.join(mcp_dir, "token.json"))
339
+ is_connected = bool(per_user or generic)
340
+ except Exception as e:
341
+ print(f"Warning: get_google_credentials() check failed: {e}")
342
+
333
343
  return {
334
344
  "has_credentials": True,
335
345
  "client_id": masked_client_id,
@@ -338,9 +348,7 @@ async def get_config():
338
348
  "user_email": user_email,
339
349
  }
340
350
  except Exception as e:
341
- mcp_token_file = os.path.join(DATA_DIR, "google-credentials", "token.json")
342
- is_connected = os.path.exists(TOKEN_FILE) and os.path.exists(mcp_token_file)
343
- return {"has_credentials": True, "error": str(e), "is_connected": is_connected}
351
+ return {"has_credentials": True, "error": str(e), "is_connected": False}
344
352
 
345
353
 
346
354
  @router.get("/api/file")
@@ -118,6 +118,15 @@ def _get_google_oauth_env() -> dict[str, str]:
118
118
  token_file = DATA_DIR / "token.json"
119
119
  if not creds_file.exists():
120
120
  return {}
121
+
122
+ # Refresh the Google token (if expired-but-refreshable) before launching
123
+ # workspace-mcp so the subprocess inherits valid creds and doesn't emit
124
+ # "ACTION REQUIRED: Google Authentication Needed" on the first tool call.
125
+ try:
126
+ from services.google import get_google_credentials
127
+ get_google_credentials()
128
+ except Exception as e:
129
+ print(f"Warning: Token refresh at startup failed: {e}")
121
130
  try:
122
131
  creds = json.loads(creds_file.read_text())
123
132
  installed = creds.get("installed", creds.get("web", {}))