Download src/agents/orchestrator.py from lablab-ai-amd-developer-hackathon/VentureForge: direct link, hf CLI and curl.
- Browser
- Download file 25.1 kB
-
https://huggingface.co/spaces/lablab-ai-amd-developer-hackathon/VentureForge/resolve/main/src/agents/orchestrator.py
- Command line
-
hf download hf://spaces/lablab-ai-amd-developer-hackathon/VentureForge/src/agents/orchestrator.py
-
curl -L -o orchestrator.py https://huggingface.co/spaces/lablab-ai-amd-developer-hackathon/VentureForge/resolve/main/src/agents/orchestrator.py
25.1 kB
| """Orchestrator — routes tasks, manages state, handles reflection loop. Never generates content.""" | |
| from __future__ import annotations | |
| import logging | |
| import time | |
| from src.state.schema import PipelineStage, VentureForgeState | |
| # Import worker agents (stubs; real implementations in agent files) | |
| from src.agents.pain_point_miner import run as run_pain_point_miner | |
| from src.agents.idea_generator import run as run_idea_generator | |
| from src.agents.scorer import run as run_scorer | |
| from src.agents.pitch_writer import run as run_pitch_writer | |
| from src.agents.critic import run as run_critic | |
| logger = logging.getLogger(__name__) | |
| def orchestrator(state: VentureForgeState) -> dict: | |
| """ | |
| Supervisor node. Based on pipeline progress, decides which worker to run next. | |
| Returns a dict patch for state update. | |
| """ | |
| logger.info( | |
| f"[orchestrator] Called with: ideas={len(state.ideas)}, scored={len(state.scored_ideas)}, " | |
| f"briefs={len(state.pitch_briefs)}, attempts={state.idea_generation_attempts}/{state.max_idea_generation_attempts}" | |
| ) | |
| stage = state.current_stage | |
| # --- Determine next stage --- | |
| if not state.pain_points: | |
| # Circuit breaker: prevent infinite loops when pain_point_miner keeps returning 0 pain points | |
| MAX_INITIAL_MINING_ATTEMPTS = 5 | |
| if state.pain_point_miner_revision_count >= MAX_INITIAL_MINING_ATTEMPTS: | |
| error_msg = ( | |
| f"Reached max initial mining attempts ({MAX_INITIAL_MINING_ATTEMPTS}) with 0 pain points. " | |
| f"This usually means: (1) LLM is failing to extract pain points from scraped content, " | |
| f"(2) All extracted pain points are failing validation (no verbatim quotes), or " | |
| f"(3) Domain '{state.domain}' has insufficient community discussion. " | |
| f"Try a different domain or check LLM logs for extraction failures." | |
| ) | |
| patch = state.mark_failed(error_msg) | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.FAILED, | |
| kind="error", | |
| message=error_msg, | |
| ) | |
| ) | |
| return patch | |
| patch = { | |
| "current_stage": PipelineStage.MINING, | |
| "next_node": "pain_point_miner", | |
| "pain_point_miner_revision_count": state.pain_point_miner_revision_count + 1, | |
| } | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.MINING, | |
| kind="info", | |
| message=f"Routing to pain_point_miner (no pain points yet, attempt {state.pain_point_miner_revision_count + 1}/{MAX_INITIAL_MINING_ATTEMPTS}).", | |
| ) | |
| ) | |
| return patch | |
| # --- Quality gate: ensure sufficient pain points before idea generation --- | |
| MIN_PAIN_POINTS_FOR_IDEAS = 2 | |
| MAX_MINING_RETRIES = 2 | |
| if not state.ideas: | |
| # Quality gate: check if we have enough pain points for robust idea generation | |
| if len(state.filtered_pain_points) < MIN_PAIN_POINTS_FOR_IDEAS: | |
| if state.pain_point_miner_revision_count < MAX_MINING_RETRIES: | |
| # Retry mining to collect more pain points | |
| patch = { | |
| "current_stage": PipelineStage.MINING, | |
| "next_node": "pain_point_miner", | |
| "pain_point_miner_revision_count": state.pain_point_miner_revision_count + 1, | |
| } | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.MINING, | |
| kind="warning", | |
| message=( | |
| f"Only {len(state.filtered_pain_points)} pain points found " | |
| f"(target: {MIN_PAIN_POINTS_FOR_IDEAS}). Retrying mining " | |
| f"(attempt {state.pain_point_miner_revision_count + 1}/{MAX_MINING_RETRIES})." | |
| ), | |
| ) | |
| ) | |
| return patch | |
| else: | |
| # Proceed with degraded quality after max retries | |
| patch = { | |
| "current_stage": PipelineStage.GENERATING, | |
| "next_node": "idea_generator", | |
| } | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.GENERATING, | |
| kind="warning", | |
| message=( | |
| f"Proceeding with only {len(state.filtered_pain_points)} pain points " | |
| f"after {MAX_MINING_RETRIES} mining attempts. Idea quality may be lower." | |
| ), | |
| ) | |
| ) | |
| return patch | |
| # Check global cap first (prevents compounding validation + revision retries) | |
| if state.idea_generation_attempts >= state.max_total_llm_calls_per_agent: | |
| error_msg = ( | |
| f"Reached global LLM call limit ({state.max_total_llm_calls_per_agent}) for idea_generator. " | |
| f"This prevents excessive retries from validation failures + Critic revisions. " | |
| f"Check logs for root cause (invalid pain_point_ids, schema mismatches, etc.)." | |
| ) | |
| patch = state.mark_failed(error_msg) | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.FAILED, | |
| kind="error", | |
| message=error_msg, | |
| ) | |
| ) | |
| return patch | |
| # Check per-run validation retry limit | |
| if state.idea_generation_attempts >= state.max_idea_generation_attempts: | |
| error_msg = ( | |
| f"Failed to generate valid ideas after {state.idea_generation_attempts} attempts. " | |
| "This usually means the LLM is not producing ideas with valid pain_point_ids. " | |
| "Check logs for validation failures." | |
| ) | |
| patch = state.mark_failed(error_msg) | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.FAILED, | |
| kind="error", | |
| message=error_msg, | |
| ) | |
| ) | |
| return patch | |
| patch = { | |
| "current_stage": PipelineStage.GENERATING, | |
| "next_node": "idea_generator", | |
| } | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.GENERATING, | |
| kind="info", | |
| message=f"Routing to idea_generator (no ideas yet, attempt {state.idea_generation_attempts + 1}/{state.max_idea_generation_attempts}, global {state.idea_generation_attempts + 1}/{state.max_total_llm_calls_per_agent}).", | |
| ) | |
| ) | |
| return patch | |
| if not state.scored_ideas: | |
| # Circuit breaker: prevent infinite loops when scorer fails to generate valid output | |
| if state.scorer_attempts >= state.max_total_llm_calls_per_agent: | |
| error_msg = ( | |
| f"Reached global LLM call limit ({state.max_total_llm_calls_per_agent}) for scorer. " | |
| f"This usually means the LLM is failing to generate valid JSON (truncation or parsing errors). " | |
| f"Check logs for 'JSON extraction failed' or 'Response may be truncated' warnings." | |
| ) | |
| patch = state.mark_failed(error_msg) | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.FAILED, | |
| kind="error", | |
| message=error_msg, | |
| ) | |
| ) | |
| return patch | |
| patch = {"current_stage": PipelineStage.SCORING, "next_node": "scorer"} | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.SCORING, | |
| kind="info", | |
| message=f"Routing to scorer (no scored ideas yet, attempt {state.scorer_attempts + 1}/{state.max_total_llm_calls_per_agent}).", | |
| ) | |
| ) | |
| return patch | |
| if not state.pitch_briefs: | |
| # Quality gate: check if we generated enough ideas before checking verdicts | |
| MIN_IDEAS_THRESHOLD = max(2, state.ideas_per_run // 2) # At least half of requested ideas | |
| if len(state.ideas) < MIN_IDEAS_THRESHOLD: | |
| # Check circuit breaker: prevent infinite loops when idea generator consistently fails | |
| logger.info( | |
| f"[orchestrator] Insufficient ideas check: {len(state.ideas)} < {MIN_IDEAS_THRESHOLD}. " | |
| f"Attempts: {state.idea_generation_attempts}/{state.max_idea_generation_attempts}" | |
| ) | |
| if state.idea_generation_attempts >= state.max_idea_generation_attempts: | |
| # Fail if we've exhausted retries and still have insufficient ideas | |
| error_msg = ( | |
| f"Failed to generate sufficient ideas after {state.idea_generation_attempts} attempts. " | |
| f"Only {len(state.ideas)} ideas generated (minimum: {MIN_IDEAS_THRESHOLD}). " | |
| "This usually means the LLM is not producing ideas with valid pain_point_ids. " | |
| "Check logs for validation failures." | |
| ) | |
| logger.error(f"[orchestrator] Circuit breaker triggered: {error_msg}") | |
| patch = state.mark_failed(error_msg) | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.FAILED, | |
| kind="error", | |
| message=error_msg, | |
| ) | |
| ) | |
| return patch | |
| if state.idea_generation_attempts < state.max_idea_generation_attempts: | |
| # Retry idea generation to get more candidates | |
| logger.info( | |
| f"[orchestrator] Retrying idea generation (attempt {state.idea_generation_attempts + 1}/{state.max_idea_generation_attempts})" | |
| ) | |
| patch = { | |
| "current_stage": PipelineStage.GENERATING, | |
| "next_node": "idea_generator", | |
| } | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.GENERATING, | |
| kind="warning", | |
| message=( | |
| f"Only {len(state.ideas)} ideas generated (target: {state.ideas_per_run}, " | |
| f"minimum: {MIN_IDEAS_THRESHOLD}). Retrying idea generation " | |
| f"(attempt {state.idea_generation_attempts + 1}/{state.max_idea_generation_attempts})." | |
| ), | |
| ) | |
| ) | |
| return patch | |
| # Check if there are unscored ideas (e.g., after retry) | |
| scored_idea_ids = {s.idea_id for s in state.scored_ideas} | |
| unscored_ideas = [idea for idea in state.ideas if idea.id not in scored_idea_ids] | |
| if unscored_ideas: | |
| # Route back to scorer for new ideas | |
| patch = {"current_stage": PipelineStage.SCORING, "next_node": "scorer"} | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.SCORING, | |
| kind="info", | |
| message=f"Found {len(unscored_ideas)} unscored ideas (after retry). Routing to scorer.", | |
| ) | |
| ) | |
| return patch | |
| # Log verdict distribution for visibility | |
| if state.top_scored_ideas: | |
| verdict_counts = { | |
| "pursue": sum(1 for s in state.top_scored_ideas if s.verdict == "pursue"), | |
| "explore": sum(1 for s in state.top_scored_ideas if s.verdict == "explore"), | |
| "park": sum(1 for s in state.top_scored_ideas if s.verdict == "park"), | |
| } | |
| logger.info( | |
| f"[orchestrator] Top {len(state.top_scored_ideas)} ideas verdict distribution: " | |
| f"pursue={verdict_counts['pursue']}, explore={verdict_counts['explore']}, park={verdict_counts['park']}" | |
| ) | |
| # If all ideas are "park", log a warning but continue to generate pitch briefs | |
| # "Park" means "interesting but has concerns" - still worth documenting | |
| if all(s.verdict == "park" for s in state.top_scored_ideas): | |
| patch = {} | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.WRITING, | |
| kind="warning", | |
| message=( | |
| f"All {len(state.top_scored_ideas)} top-scored ideas received 'park' verdict " | |
| f"(interesting but has concerns). Generating pitch briefs for documentation. " | |
| f"Consider: (1) adjusting domain for better pain points, " | |
| f"(2) increasing ideas_per_run for more candidates, or " | |
| f"(3) reviewing scorer rubric if verdicts seem too harsh." | |
| ), | |
| ) | |
| ) | |
| # Don't return here - continue to pitch_writer | |
| # Circuit breaker: prevent infinite loops when pitch_writer fails to generate briefs | |
| if state.pitch_writer_attempts >= state.max_total_llm_calls_per_agent: | |
| error_msg = ( | |
| f"Reached global LLM call limit ({state.max_total_llm_calls_per_agent}) for pitch_writer. " | |
| f"This usually means the LLM is failing to generate valid JSON (truncation or parsing errors). " | |
| f"Check logs for 'JSON extraction failed' or 'Response may be truncated' warnings." | |
| ) | |
| patch = state.mark_failed(error_msg) | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.FAILED, | |
| kind="error", | |
| message=error_msg, | |
| ) | |
| ) | |
| return patch | |
| patch = {"current_stage": PipelineStage.WRITING, "next_node": "pitch_writer"} | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.WRITING, | |
| kind="info", | |
| message=f"Routing to pitch_writer (no pitch briefs yet, attempt {state.pitch_writer_attempts + 1}/{state.max_total_llm_calls_per_agent}).", | |
| ) | |
| ) | |
| return patch | |
| # We have pitch_briefs. Now check if we need to critique them. | |
| if state.critique is None: | |
| # ✅ MAJOR FIX #6: Validate pitch_briefs match top_scored_ideas | |
| top_ids = {s.idea_id for s in state.top_scored_ideas} | |
| brief_ids = {b.idea_id for b in state.pitch_briefs} | |
| if not brief_ids.issubset(top_ids): | |
| error_msg = ( | |
| f"Pitch briefs contain ideas not in top_scored_ideas. " | |
| f"Brief IDs: {brief_ids}, Top IDs: {top_ids}. " | |
| f"This indicates a bug in pitch_writer or scorer." | |
| ) | |
| patch = state.mark_failed(error_msg) | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.FAILED, | |
| kind="error", | |
| message=error_msg, | |
| ) | |
| ) | |
| return patch | |
| # First critique - start with index 0 | |
| patch = {"current_stage": PipelineStage.CRITIQUING, "next_node": "critic"} | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.CRITIQUING, | |
| kind="info", | |
| message=f"Routing to critic (reviewing brief {state.current_critique_index + 1}/{len(state.pitch_briefs)}).", | |
| ) | |
| ) | |
| return patch | |
| # --- Reflection loop: we have a critique --- | |
| if not state.critique.all_pass and state.can_revise: | |
| # Loop back to target worker for revision | |
| target = state.critique.target_agent | |
| # ✅ CRITICAL FIX #3: Enforce global LLM call limits during revision | |
| if target == "idea_generator": | |
| if state.idea_generation_attempts >= state.max_total_llm_calls_per_agent: | |
| error_msg = ( | |
| f"Reached global LLM call limit ({state.max_total_llm_calls_per_agent}) " | |
| f"for idea_generator during revision loop. " | |
| f"Check logs for validation failures or infinite revision loops." | |
| ) | |
| patch = state.mark_failed(error_msg) | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.FAILED, | |
| kind="error", | |
| message=error_msg, | |
| ) | |
| ) | |
| return patch | |
| elif target == "pitch_writer": | |
| if state.pitch_writer_attempts >= state.max_total_llm_calls_per_agent: | |
| error_msg = ( | |
| f"Reached global LLM call limit ({state.max_total_llm_calls_per_agent}) " | |
| f"for pitch_writer during revision loop. " | |
| f"Check logs for JSON parsing failures or infinite revision loops." | |
| ) | |
| patch = state.mark_failed(error_msg) | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.FAILED, | |
| kind="error", | |
| message=error_msg, | |
| ) | |
| ) | |
| return patch | |
| # Continue with revision | |
| patch = state.bump_revision(state.critique) | |
| patch.update(state.reset_for_revision(target, state.critique.idea_id)) | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.REVISING, | |
| kind="info", | |
| message=( | |
| f"Revision requested by critic for idea {state.critique.idea_id} " | |
| f"→ target_agent={target}." | |
| ), | |
| idea_id=state.critique.idea_id, | |
| ) | |
| ) | |
| return patch | |
| # ✅ POST-REVISION PIPELINE COMPLETION CHECK | |
| # After a revision, we may have new ideas that need scoring/pitching before returning to Critic | |
| # Check for unscored ideas (after idea_generator revision) | |
| scored_idea_ids = {s.idea_id for s in state.scored_ideas} | |
| unscored_ideas = [idea for idea in state.ideas if idea.id not in scored_idea_ids] | |
| if unscored_ideas: | |
| logger.info( | |
| f"[orchestrator] Found {len(unscored_ideas)} unscored ideas after revision. " | |
| f"Routing to scorer before returning to critic." | |
| ) | |
| patch = {"current_stage": PipelineStage.SCORING, "next_node": "scorer"} | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.SCORING, | |
| kind="info", | |
| message=f"Found {len(unscored_ideas)} unscored ideas after revision. Routing to scorer.", | |
| ) | |
| ) | |
| return patch | |
| # Check for unpitched scored ideas (after scorer revision or idea_generator → scorer) | |
| if state.top_scored_ideas: | |
| top_ids = {s.idea_id for s in state.top_scored_ideas} | |
| brief_ids = {b.idea_id for b in state.pitch_briefs} | |
| missing_brief_ids = top_ids - brief_ids | |
| if missing_brief_ids: | |
| logger.info( | |
| f"[orchestrator] Found {len(missing_brief_ids)} ideas without pitch briefs after revision. " | |
| f"Routing to pitch_writer before returning to critic." | |
| ) | |
| patch = {"current_stage": PipelineStage.WRITING, "next_node": "pitch_writer"} | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.WRITING, | |
| kind="info", | |
| message=f"Found {len(missing_brief_ids)} ideas without pitch briefs after revision. Routing to pitch_writer.", | |
| ) | |
| ) | |
| return patch | |
| # All ideas are scored and pitched - continue with critique workflow | |
| # --- Critique passed or max revisions reached --- | |
| # Check if there are more briefs to critique | |
| if state.current_critique_index + 1 < len(state.pitch_briefs): | |
| # Move to next brief | |
| patch = { | |
| "current_critique_index": state.current_critique_index + 1, | |
| "critique": None, # Clear critique for next brief | |
| "revision_feedback": None, # Clear stale feedback | |
| "current_stage": PipelineStage.CRITIQUING, | |
| "next_node": "critic", | |
| } | |
| # Log different messages based on approval status | |
| if state.critique.approval_status == "max_revisions_reached": | |
| message = ( | |
| f"Brief {state.current_critique_index + 1} reached max revisions " | |
| f"(still has {len(state.critique.failing_checks)} failing checks). " | |
| f"Moving to brief {state.current_critique_index + 2}/{len(state.pitch_briefs)}." | |
| ) | |
| else: | |
| message = f"Brief {state.current_critique_index + 1} approved. Moving to brief {state.current_critique_index + 2}/{len(state.pitch_briefs)}." | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.CRITIQUING, | |
| kind="warning" if state.critique.approval_status == "max_revisions_reached" else "info", | |
| message=message, | |
| ) | |
| ) | |
| return patch | |
| # --- All briefs critiqued --- | |
| # Check if any briefs were forced through at max revisions | |
| max_revision_briefs = [ | |
| c for c in state.critiques | |
| if c.approval_status == "max_revisions_reached" | |
| ] | |
| if max_revision_briefs: | |
| summary = ( | |
| f"Pipeline completed with {len(state.pain_points)} pain points, " | |
| f"{len(state.ideas)} ideas, {len(state.scored_ideas)} scored ideas, " | |
| f"and {len(state.pitch_briefs)} pitch briefs. " | |
| f"⚠️ WARNING: {len(max_revision_briefs)} brief(s) reached max revisions with unresolved quality issues." | |
| ) | |
| kind = "warning" | |
| else: | |
| summary = ( | |
| f"Pipeline completed with {len(state.pain_points)} pain points, " | |
| f"{len(state.ideas)} ideas, {len(state.scored_ideas)} scored ideas, " | |
| f"and {len(state.pitch_briefs)} pitch briefs (all approved)." | |
| ) | |
| kind = "info" | |
| patch = state.mark_completed() | |
| patch["revision_feedback"] = None # Clear stale feedback | |
| patch.update( | |
| state.add_event( | |
| agent="orchestrator", | |
| stage=PipelineStage.COMPLETED, | |
| kind=kind, | |
| message=summary, | |
| ) | |
| ) | |
| return patch | |
| # Worker wrapper nodes (LangGraph calls these, they call the agent logic) | |
| def pain_point_miner(state: VentureForgeState) -> dict: | |
| t0 = time.monotonic() | |
| result = run_pain_point_miner(state) | |
| elapsed = time.monotonic() - t0 | |
| return {**result, **state.record_timing("pain_point_miner", elapsed)} | |
| def idea_generator(state: VentureForgeState) -> dict: | |
| t0 = time.monotonic() | |
| result = run_idea_generator(state) | |
| elapsed = time.monotonic() - t0 | |
| # After generating, always clear scored_ideas/pitch_briefs in case of revision | |
| patch = {**result, **state.record_timing("idea_generator", elapsed)} | |
| return patch | |
| def scorer(state: VentureForgeState) -> dict: | |
| t0 = time.monotonic() | |
| result = run_scorer(state) | |
| elapsed = time.monotonic() - t0 | |
| return {**result, **state.record_timing("scorer", elapsed)} | |
| def pitch_writer(state: VentureForgeState) -> dict: | |
| t0 = time.monotonic() | |
| result = run_pitch_writer(state) | |
| elapsed = time.monotonic() - t0 | |
| return {**result, **state.record_timing("pitch_writer", elapsed)} | |
| def critic(state: VentureForgeState) -> dict: | |
| t0 = time.monotonic() | |
| result = run_critic(state) | |
| elapsed = time.monotonic() - t0 | |
| return {**result, **state.record_timing("critic", elapsed)} | |