Feat: Added progress bar as a built-in tool and added it to finish command

This commit is contained in:
2025-12-16 13:16:50 -05:00
parent d829afc01e
commit 10ad86df60
4 changed files with 404 additions and 88 deletions
+170 -87
View File
@@ -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"
+41
View File
@@ -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"]
+8 -1
View File
@@ -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
+185
View File
@@ -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