synapse-orch-ai 1.6.1 → 1.6.3

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 (62) hide show
  1. package/backend/core/builder_tools.py +5 -4
  2. package/backend/core/mcp_client.py +7 -0
  3. package/backend/core/models_orchestration.py +6 -1
  4. package/backend/core/native_builder/__init__.py +1 -1
  5. package/backend/core/orchestration/context.py +191 -54
  6. package/backend/core/orchestration/engine.py +130 -0
  7. package/backend/core/orchestration/steps.py +35 -8
  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 +2 -2
  32. package/frontend-build/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  33. package/frontend-build/.next/server/app/index.segments/_full.segment.rsc +2 -2
  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/page_client-reference-manifest.js +1 -1
  46. package/frontend-build/.next/server/app/settings/[tab]/page_client-reference-manifest.js +1 -1
  47. package/frontend-build/.next/server/chunks/ssr/_0b~n.nn._.js +2 -2
  48. package/frontend-build/.next/server/chunks/ssr/_0rwng5.._.js +1 -1
  49. package/frontend-build/.next/server/chunks/ssr/src_app_page_tsx_0ss2.w7._.js +1 -1
  50. package/frontend-build/.next/server/middleware-build-manifest.js +3 -3
  51. package/frontend-build/.next/server/middleware-manifest.json +5 -5
  52. package/frontend-build/.next/server/pages/404.html +1 -1
  53. package/frontend-build/.next/server/pages/500.html +1 -1
  54. package/frontend-build/.next/server/server-reference-manifest.js +1 -1
  55. package/frontend-build/.next/server/server-reference-manifest.json +1 -1
  56. package/frontend-build/.next/static/chunks/{0w5f~4w31j03v.js → 02k3e9vyxajlw.js} +1 -1
  57. package/frontend-build/.next/static/chunks/{0htooj7jtj_tj.js → 0v0eoz45t7rie.js} +1 -1
  58. package/frontend-build/.next/static/chunks/{0-h50ksae_qf6.js → 0xjv-b-~qzwt1.js} +22 -22
  59. package/package.json +1 -1
  60. /package/frontend-build/.next/static/{NvFb2r3QeKnSOYYi1xaC7 → cGnS1OeVwebS_oHgbU140}/_buildManifest.js +0 -0
  61. /package/frontend-build/.next/static/{NvFb2r3QeKnSOYYi1xaC7 → cGnS1OeVwebS_oHgbU140}/_clientMiddlewareManifest.js +0 -0
  62. /package/frontend-build/.next/static/{NvFb2r3QeKnSOYYi1xaC7 → cGnS1OeVwebS_oHgbU140}/_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
  },
@@ -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
@@ -145,6 +147,9 @@ class OrchestrationRun(BaseModel):
145
147
  waiting_for_human: bool = False
146
148
  human_prompt: str | None = None
147
149
  human_fields: list[dict[str, str]] = []
150
+ # Nested-orchestration human-in-the-loop tracking
151
+ nested_run_id: str | None = None # sub-run paused waiting for human input
152
+ nested_orch_id: str | None = None # sub-orchestration definition ID
148
153
 
149
154
  # Cost tracking
150
155
  total_tokens_used: int = 0
@@ -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
@@ -175,6 +175,14 @@ class OrchestrationEngine:
175
175
  run.waiting_for_human = True
176
176
  run.status = "paused"
177
177
  run.current_step_id = step.id
178
+ # Track the sub-run that needs human input when the
179
+ # request originated inside a nested orchestration.
180
+ if event.get("nested_run_id"):
181
+ run.nested_run_id = event["nested_run_id"]
182
+ run.nested_orch_id = event.get("nested_orch_id")
183
+ else:
184
+ run.nested_run_id = None
185
+ run.nested_orch_id = None
178
186
  state.checkpoint()
179
187
  if logger:
180
188
  logger.step_end(step.id, "paused")
@@ -370,6 +378,14 @@ class OrchestrationEngine:
370
378
  session_id=run.session_id,
371
379
  )
372
380
 
381
+ # If the parent was paused because a NESTED orchestration hit a human step,
382
+ # resume the sub-run and let it complete before continuing the parent.
383
+ if run.nested_run_id:
384
+ async for event in cls._resume_nested_orch(run, engine, human_response, server_module):
385
+ yield event
386
+ return
387
+
388
+ # Normal path: human step is directly in this orchestration.
373
389
  # Move to next step after the HUMAN step
374
390
  current_step = engine.step_map.get(run.current_step_id)
375
391
 
@@ -392,6 +408,120 @@ class OrchestrationEngine:
392
408
  async for event in engine._execute_loop(run, state):
393
409
  yield event
394
410
 
411
+ @classmethod
412
+ async def _resume_nested_orch(
413
+ cls,
414
+ run: OrchestrationRun,
415
+ engine: "OrchestrationEngine",
416
+ human_response: dict,
417
+ server_module,
418
+ ) -> AsyncGenerator[dict, None]:
419
+ """Resume a parent run whose nested sub-orchestration was paused at a human step.
420
+
421
+ Resumes the sub-run, forwards its events tagged with the parent step's
422
+ context, then writes the sub-orch result to parent shared_state and
423
+ continues the parent execution loop.
424
+ """
425
+ parent_step = engine.step_map.get(run.current_step_id)
426
+ nested_run_id = run.nested_run_id
427
+ nested_orch_id = run.nested_orch_id
428
+ _NESTED_FILTER_TYPES = {"orchestration_start", "orchestration_complete", "orchestration_end"}
429
+
430
+ final_response: str | None = None
431
+ sub_events: list[dict] = []
432
+
433
+ async for sub_event in cls.resume(nested_run_id, human_response, server_module):
434
+ sub_events.append(sub_event)
435
+
436
+ # Sub-orch hit another human step (multi-turn human interaction inside nested orch)
437
+ if sub_event.get("type") == "human_input_required":
438
+ run.nested_run_id = sub_event.get("nested_run_id") or nested_run_id
439
+ run.nested_orch_id = sub_event.get("nested_orch_id") or nested_orch_id
440
+ state = SharedState(run)
441
+ state.checkpoint()
442
+ yield {
443
+ **sub_event,
444
+ "run_id": run.run_id,
445
+ "orch_step_id": run.current_step_id,
446
+ "step_name": parent_step.name if parent_step else "",
447
+ "nested_run_id": nested_run_id,
448
+ "nested_orch_id": nested_orch_id,
449
+ }
450
+ return
451
+
452
+ if sub_event.get("type") == "final" and sub_event.get("intent") == "orchestration":
453
+ final_response = sub_event.get("response", "")
454
+
455
+ # Skip sub-orch lifecycle meta-events (same filter as initial execution)
456
+ if sub_event.get("type") in _NESTED_FILTER_TYPES:
457
+ continue
458
+
459
+ yield {
460
+ **sub_event,
461
+ "run_id": run.run_id,
462
+ "orch_step_id": run.current_step_id,
463
+ "step_name": parent_step.name if parent_step else "",
464
+ "nested_run_id": nested_run_id,
465
+ "nested_orch_id": nested_orch_id,
466
+ }
467
+
468
+ # Fallback: extract result from orchestration_complete if no "final" event was emitted
469
+ if final_response is None:
470
+ from core.routes.orchestrations import load_orchestrations
471
+ from core.models_orchestration import Orchestration
472
+ orchs = load_orchestrations()
473
+ sub_orch_data = next((o for o in orchs if o["id"] == nested_orch_id), None)
474
+ if sub_orch_data:
475
+ sub_orch = Orchestration.model_validate(sub_orch_data)
476
+ for ev in reversed(sub_events):
477
+ if ev.get("type") == "orchestration_complete":
478
+ state_data = ev.get("final_state") or {}
479
+ for sub_step in reversed(sub_orch.steps):
480
+ if sub_step.output_key and sub_step.output_key in state_data:
481
+ final_response = str(state_data[sub_step.output_key])
482
+ break
483
+ break
484
+
485
+ if final_response is None:
486
+ run.status = "failed"
487
+ state = SharedState(run)
488
+ state.checkpoint()
489
+ yield {
490
+ "type": "orchestration_error",
491
+ "error": "Nested orchestration failed to produce a result after human input",
492
+ }
493
+ return
494
+
495
+ # Write sub-orch result into parent shared_state
496
+ if parent_step and parent_step.output_key:
497
+ run.shared_state[parent_step.output_key] = final_response
498
+
499
+ # Record agent step completion in history
500
+ import time as _time
501
+ run.step_history.append({
502
+ "step_id": run.current_step_id,
503
+ "step_name": parent_step.name if parent_step else "",
504
+ "step_type": "agent",
505
+ "status": "completed",
506
+ "ended_at": _time.strftime("%Y-%m-%dT%H:%M:%SZ", _time.gmtime()),
507
+ })
508
+
509
+ # Clear nested context and resume parent from next step after agent step
510
+ run.nested_run_id = None
511
+ run.nested_orch_id = None
512
+ run.waiting_for_human = False
513
+ run.status = "running"
514
+
515
+ if parent_step:
516
+ next_id, extra_event = engine._resolve_next(parent_step, run)
517
+ if extra_event:
518
+ yield extra_event
519
+ run.current_step_id = next_id
520
+
521
+ state = SharedState(run)
522
+ async for event in engine._execute_loop(run, state):
523
+ yield event
524
+
395
525
  def _init_state(self, user_input: str) -> dict:
396
526
  """Initialize shared state from schema defaults + user input."""
397
527
  state = {}