diff --git a/examples/scientific_paper_writer.yaml b/examples/scientific_paper_writer.yaml index aa22ce92..497e55e2 100644 --- a/examples/scientific_paper_writer.yaml +++ b/examples/scientific_paper_writer.yaml @@ -370,8 +370,24 @@ agents: def has_structure(): return bool(context.get('table_of_contents')) + def report_progress(stage_name: str, note: str = ""): + try: + from cleveragents.core.progress import ProgressBarManager + + ProgressBarManager.update( + stage=stage_name, + context=context, + message=note, + ) + except Exception as progress_err: # pylint: disable=broad-except + print( + f"PROGRESS_BAR_ERROR {progress_err}", + file=sys.stderr, + ) + def start_stage(stage_name): context['writing_stage'] = stage_name + report_progress(stage_name, "Auto-finish in progress") if stage_name == 'brainstorming': set_expect('brainstorming_output') return ("GOTO_BRAINSTORMING:Auto-finish mode is active. Using the captured requirements, " @@ -391,6 +407,23 @@ agents: return ("COMMAND_OUTPUT:Auto-finish cannot start section writing because the structure stage " "is incomplete. Please finish the structure before running !finish.") label = current_section_label() + section_paths = context.get('section_paths') or [] + report_progress( + stage_name, + f"Sections {context.get('current_section_index', 0)}/{len(section_paths)}", + ) + try: + from cleveragents.core.progress import ProgressBarManager + + ProgressBarManager.update( + stage=stage_name, + current=context.get('current_section_index', 0), + total=len(section_paths) if section_paths else 1, + message=f"Sections {context.get('current_section_index', 0)}/{len(section_paths)}", + context=context, + ) + except Exception: + pass set_expect('source_selector_output') return f"GOTO_SECTION_WRITING:{label or ''}" if stage_name == 'paper_review': @@ -401,6 +434,7 @@ agents: # Keep auto_finish_active=True to maintain high depth limit # Mark that we're in final stage context['auto_finish_final_stage'] = True + report_progress(stage_name, "Generating LaTeX and compiling") return "GOTO_LATEX_GENERATION:" # Only set auto_finish_active=False at the very end context['auto_finish_active'] = False @@ -426,6 +460,22 @@ agents: label = current_section_label() context['current_section_path'] = label set_expect('source_selector_output') + report_progress( + 'section_writing', + f"Completed section {current_idx} of {len(section_paths)}", + ) + try: + from cleveragents.core.progress import ProgressBarManager + + ProgressBarManager.update( + stage='section_writing', + current=current_idx, + total=len(section_paths) if section_paths else 1, + message=f"Completed section {current_idx} of {len(section_paths)}", + context=context, + ) + except Exception: + pass next_action = f"GOTO_SECTION_WRITING:{label}" elif expect == 'brainstorming_output': next_action = start_stage('vetting') @@ -1083,85 +1133,83 @@ agents: tools: - name: save_parsed_toc code: | - import sys - import json - print(f"DEBUG toc_parser_saver", file=sys.stderr) + import sys + import json + print("DEBUG toc_parser_saver", file=sys.stderr) - # Flatten sections iteratively (no recursion) - def flatten_sections(sections): - """Flatten nested sections into a list with full paths using iteration.""" - flat_list = [] - # Stack holds tuples of (section_list, parent_path, index) - stack = [(sections, "", 0)] - - while stack: - current_sections, parent_path, idx = stack.pop() - - # Process remaining items in current_sections starting from idx - while idx < len(current_sections): - section = current_sections[idx] - title = section.get('title', '') - - if title: - # Build the full path for this section - current_path = parent_path + " > " + title if parent_path else title - flat_list.append(current_path) - - # Check for subsections - subsections = section.get('subsections', []) - if subsections: - # Save current position to return to later - stack.append((current_sections, parent_path, idx + 1)) - # Start processing subsections - stack.append((subsections, current_path, 0)) - break - - idx = idx + 1 - - return flat_list + def flatten_sections(sections): + """Flatten nested sections into a list with full paths using iteration.""" + flat_list = [] + stack = [(sections, "", 0)] - # Parse the JSON response from Gemini - try: - # Strip markdown code block markers if present - json_text = input_data.strip() - if json_text.startswith('```'): - lines = json_text.split('\n') - if lines[0].startswith('```'): - lines = lines[1:] - if lines[-1].strip() == '```': - lines = lines[:-1] - json_text = '\n'.join(lines) - - toc_data = json.loads(json_text) - - # Handle both formats: {"sections": [...]} or just [...] - if type(toc_data) == list: - sections = toc_data - elif type(toc_data) == dict: - sections = toc_data.get('sections', []) - else: - sections = [] + while stack: + current_sections, parent_path, idx = stack.pop() + while idx < len(current_sections): + section = current_sections[idx] + title = section.get("title", "") + if title: + current_path = f"{parent_path} > {title}" if parent_path else title + flat_list.append(current_path) + subsections = section.get("subsections", []) + if subsections: + stack.append((current_sections, parent_path, idx + 1)) + stack.append((subsections, current_path, 0)) + break + idx += 1 - # Flatten all sections and subsections - section_paths = flatten_sections(sections) + return flat_list + + try: + json_text = input_data.strip() + if json_text.startswith("```"): + lines = json_text.split("\n") + if lines and lines[0].startswith("```"): + lines = lines[1:] + if lines and lines[-1].strip() == "```": + lines = lines[:-1] + json_text = "\n".join(lines) + + toc_data = json.loads(json_text) + + if isinstance(toc_data, list): + sections = toc_data + elif isinstance(toc_data, dict): + sections = toc_data.get("sections", []) + else: + sections = [] + + section_paths = flatten_sections(sections) + context["section_paths"] = section_paths + context["current_section_index"] = 0 + + try: + from cleveragents.core.progress import ProgressBarManager + + ProgressBarManager.update( + stage=context.get("writing_stage", "section_writing") or "section_writing", + current=0, + total=len(section_paths) if section_paths else 1, + message="Preparing sections", + context=context, + ) + except Exception: + pass + + if section_paths: + first_section = section_paths[0] + context["current_section_path"] = first_section + result = f"ROUTE_SELECT_SOURCES:{first_section}" + else: + result = "ERROR: No sections found in table of contents" + + except Exception: + context["section_paths"] = ["Introduction", "Methods", "Results", "Discussion"] + context["current_section_index"] = 0 + first_section = context["section_paths"][0] + context["current_section_path"] = first_section + result = f"ROUTE_SELECT_SOURCES:{first_section}" - context['section_paths'] = section_paths - context['current_section_index'] = 0 - # Route to first section - if section_paths: - first_section = section_paths[0] - context['current_section_path'] = first_section - result = f"ROUTE_SELECT_SOURCES:{first_section}" - else: - result = "ERROR: No sections found in table of contents" - except: - # Fallback to generic sections - context['section_paths'] = ['Introduction', 'Methods', 'Results', 'Discussion'] - context['current_section_index'] = 0 - first_section = context['section_paths'][0] - context['current_section_path'] = first_section - result = f"ROUTE_SELECT_SOURCES:{first_section}" source_selector: type: llm @@ -1393,12 +1441,28 @@ agents: code: | current_index = context.get('current_section_index', 0) section_paths = context.get('section_paths') or [] - completed_section = section_paths[current_index] if current_index < len(section_paths) else 'Unknown' + completed_section = ( + section_paths[current_index] if current_index < len(section_paths) else 'Unknown' + ) # Advance to the next section and update the current path so auto-finish progresses context['current_section_index'] = current_index + 1 new_index = current_index + 1 + # Progress update after completing a section + try: + from cleveragents.core.progress import ProgressBarManager + + ProgressBarManager.update( + stage=context.get('writing_stage', 'section_writing') or 'section_writing', + current=new_index, + total=len(section_paths) if section_paths else 1, + message=f"Completed {new_index}/{len(section_paths)} sections", + context=context, + ) + except Exception: + pass + if new_index >= len(section_paths): # All sections complete; clear current section path context['current_section_path'] = None @@ -1409,6 +1473,8 @@ agents: # Include unique info in message to avoid loop detection result = f"ROUTE_NEXT_SECTION:{next_section}|completed={completed_section}|idx={new_index}" + + # ============================================ # SECTION PROOFREADING # ============================================ @@ -1518,19 +1584,29 @@ agents: assembled = full_text.strip() context['assembled_paper'] = assembled - # Don't print full paper during auto-finish - it will be shown at the end auto_active = context.get('auto_finish_active', False) - if not auto_active: - preview_output = ( - f"# Full Paper\n\n{assembled}" if assembled else "# Full Paper\n\n(No content available.)" - ) - print(preview_output, flush=True) - - instruction = ( - "Please review the assembled paper stored in context['assembled_paper'] " - "for coherence, grammar, citations, and overall flow." + already_printed_auto = context.get('paper_review_printed_auto', False) + preview_output = ( + f"# Full Paper\n\n{assembled}" if assembled else "# Full Paper\n\n(No content available.)" ) - result = f"ROUTE_REVIEW_PAPER:{instruction}" + + # Print the assembled paper once; avoid duplicates in auto-finish + if not (auto_active and already_printed_auto): + print(preview_output, flush=True) + if auto_active: + context['paper_review_printed_auto'] = True + + if auto_active: + result = ( + "AUTO_PAPER_REVIEW_COMPLETE:Auto-finish mode printed the full paper; " + "skipping LLM review and proceeding to LaTeX generation." + ) + else: + instruction = ( + "Please review the assembled paper stored in context['assembled_paper'] " + "for coherence, grammar, citations, and overall flow." + ) + result = f"ROUTE_REVIEW_PAPER:{instruction}" paper_review_agent: type: llm @@ -2071,15 +2147,22 @@ routes: target: auto_finish_driver extract_message: true separator: ":" + + - match_type: prefix + pattern: "AUTO_PAPER_REVIEW_COMPLETE" + target: auto_finish_driver + extract_message: true + separator: ":" # Paper review routing - + - match_type: prefix pattern: "ROUTE_REVIEW_PAPER" target: paper_review_agent extract_message: true separator: ":" + # LaTeX generation routing patterns - match_type: prefix pattern: "ROUTE_GEN_STRUCTURE" diff --git a/src/cleveragents/agents/tool.py b/src/cleveragents/agents/tool.py index 8245a68d..0e0133f6 100644 --- a/src/cleveragents/agents/tool.py +++ b/src/cleveragents/agents/tool.py @@ -71,6 +71,7 @@ class ToolAgent(Agent): "http_request": self._http_request_tool, "file_read": self._file_read_tool, "file_write": self._file_write_tool, + "progress_bar": self._progress_bar_tool, } # Validate tools @@ -740,6 +741,46 @@ class ToolAgent(Agent): logger.error("File write failed for %s: %s", filepath, e) raise ExecutionError(f"File write failed: {e}") from e + async def _progress_bar_tool( + self, args: dict[str, Any], context: Optional[dict[str, Any]] + ) -> str: + """Render or update a simple progress bar directly to stdout.""" + + try: + from cleveragents.core.progress import ProgressBarManager + except Exception as e: # pylint: disable=broad-exception-caught + logger.error("Progress bar helper unavailable: %s", e) + raise ExecutionError("Progress bar helper unavailable") from e + + def _to_int(value: Any) -> Optional[int]: + try: + return int(value) + except (TypeError, ValueError): + return None + + stage = args.get("stage") or args.get("phase") + message = args.get("message") or args.get("label") + total = _to_int(args.get("total") or args.get("steps") or args.get("count")) + current = _to_int(args.get("current") or args.get("done")) + remaining = _to_int(args.get("remaining")) + sections_total = _to_int(args.get("sections_total")) + sections_completed = _to_int( + args.get("sections_completed") or args.get("completed_sections") + ) + + rendered = ProgressBarManager.update( + stage=stage, + current=current, + total=total, + remaining=remaining, + sections_total=sections_total, + sections_completed=sections_completed, + message=message, + context=context, + ) + + return f"Progress updated: {rendered}" + def get_capabilities(self) -> List[str]: """Get the capabilities of the tool agent.""" capabilities = ["tool-execution", "command-execution"] diff --git a/src/cleveragents/core/application.py b/src/cleveragents/core/application.py index b1b80847..6476886b 100644 --- a/src/cleveragents/core/application.py +++ b/src/cleveragents/core/application.py @@ -1255,7 +1255,14 @@ class ReactiveCleverAgentsApp: # pylint: disable=too-many-instance-attributes # Prepare tool request tool_request = json.dumps({"tool": tool_name, "args": tool_params}) - context = {"_unsafe_mode": self.unsafe} + context: dict[str, Any] = {"_unsafe_mode": self.unsafe} + if self.config and self.config.global_context: + try: + context.update(self.config.global_context) + except Exception as e: # pylint: disable=broad-except + self.logger.debug( + "Unable to merge global context into tool call: %s", e + ) # Execute based on whether we're in an async context result = None diff --git a/src/cleveragents/core/progress.py b/src/cleveragents/core/progress.py new file mode 100644 index 00000000..111954f4 --- /dev/null +++ b/src/cleveragents/core/progress.py @@ -0,0 +1,185 @@ +"""Lightweight progress bar rendering utilities for CleverAgents. + +This module provides a small, dependency-free progress bar helper that can be +invoked from built-in tools or inline Python tools to display long-running +workflow progress (e.g., auto-finish in scientific paper writer). The +ProgressBarManager keeps minimal state and writes updates directly to stdout so +it can surface progress even when LangGraph routing is bypassed. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import sys +import threading +from typing import Any, Optional + + +@dataclass +class ProgressSnapshot: + """Represents a single progress bar state.""" + + stage: str = "" + current: int = 0 + total: int = 1 + message: str = "" + + @property + def percent(self) -> float: + if self.total <= 0: + return 0.0 + return max(0.0, min(1.0, self.current / float(self.total))) + + +class ProgressBarManager: + """Renders and tracks a simple textual progress bar. + + The manager is intentionally lightweight and thread-safe; it stores the last + snapshot so subsequent updates can fill in missing values (e.g., keep the + previous total when only the current count changes). + """ + + _lock = threading.Lock() + _snapshot = ProgressSnapshot() + _bar_width = 24 + + @classmethod + def _derive_from_context( + cls, context: Optional[dict[str, Any]] + ) -> ProgressSnapshot: + """Best-effort extraction of progress info from workflow context.""" + + if not isinstance(context, dict): + return ProgressSnapshot() + + stage = str(context.get("writing_stage", "") or "") + section_paths = context.get("section_paths") or [] + total_sections = len(section_paths) if isinstance(section_paths, list) else 0 + current_section_index = int(context.get("current_section_index", 0) or 0) + latex_index = int(context.get("current_latex_index", 0) or 0) + + if stage == "section_writing" and total_sections: + message = f"Writing sections ({current_section_index}/{total_sections})" + return ProgressSnapshot( + stage=stage, + current=max(0, min(current_section_index, total_sections)), + total=total_sections, + message=message, + ) + + if stage == "latex_generation" and total_sections: + message = f"LaTeX conversion ({latex_index}/{total_sections})" + return ProgressSnapshot( + stage=stage, + current=max(0, min(latex_index, total_sections)), + total=total_sections, + message=message, + ) + + return ProgressSnapshot(stage=stage) + + @classmethod + def update( + cls, + *, + stage: Optional[str] = None, + current: Optional[int] = None, + total: Optional[int] = None, + remaining: Optional[int] = None, + sections_total: Optional[int] = None, + sections_completed: Optional[int] = None, + message: Optional[str] = None, + context: Optional[dict[str, Any]] = None, + ) -> str: + """Update the progress bar and return the rendered line. + + The method is forgiving: callers can provide any combination of current, + total, remaining, or contextual information. Missing pieces will be + backfilled from the previous snapshot or inferred from the provided + context. + """ + + with cls._lock: + base = ProgressSnapshot(**vars(cls._snapshot)) + context_snapshot = cls._derive_from_context(context) + + resolved_stage = (stage or context_snapshot.stage or base.stage).strip() + + resolved_total = next( + ( + v + for v in ( + total, + sections_total, + context_snapshot.total if context_snapshot.total > 0 else None, + base.total if base.total > 0 else None, + ) + if v is not None + ), + 1, + ) + + resolved_current = next( + ( + v + for v in ( + current, + sections_completed, + context_snapshot.current, + None + if remaining is None or resolved_total is None + else max(0, resolved_total - remaining), + base.current, + ) + if v is not None + ), + 0, + ) + + resolved_current = max(0, min(resolved_current, resolved_total)) + + resolved_message = ( + message + or context_snapshot.message + or base.message + or ("Stage in progress" if resolved_stage else "Working") + ) + + snapshot = ProgressSnapshot( + stage=resolved_stage, + current=resolved_current, + total=resolved_total, + message=resolved_message, + ) + + cls._snapshot = snapshot + + rendered = cls._render(snapshot) + return rendered + + @classmethod + def _render(cls, snapshot: ProgressSnapshot) -> str: + pct = snapshot.percent + filled = int(pct * cls._bar_width) + empty = cls._bar_width - filled + bar = "█" * filled + "░" * empty + + label_parts = [] + if snapshot.stage: + label_parts.append(snapshot.stage) + if snapshot.message: + label_parts.append(snapshot.message) + label = " • ".join(label_parts).strip() + + line = f"[{bar}] {pct * 100:5.1f}% ({snapshot.current}/{snapshot.total})" + if label: + line = f"{line} {label}" + + # Print to stdout immediately so progress is visible even without router output + sys.stdout.write("\r" + line) + sys.stdout.flush() + if pct >= 1.0: + sys.stdout.write("\n") + sys.stdout.flush() + + return line