Skip to content

Commit 9361f46

Browse files
committed
feat(trace,workflow): terminal trace timeline, DAG diagram, run waterfall
1 parent 4930802 commit 9361f46

15 files changed

Lines changed: 872 additions & 40 deletions

effgen/cli/_main.py

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,9 +1101,25 @@ def run_agent(self, args):
11011101
if not quiet:
11021102
_progress.print_summary(self, response)
11031103

1104-
# Display explain trace (tool reasoning)
1105-
if getattr(args, 'explain', False) and response.execution_trace:
1106-
self.print_header("Execution Trace (Explain Mode)")
1104+
_explain = getattr(args, 'explain', False)
1105+
_trace = getattr(args, 'trace', False)
1106+
1107+
# A per-step timeline (bars + durations) shows where the
1108+
# wall-clock went across the run's steps.
1109+
if _trace and response.execution_trace:
1110+
self.print_header("Timeline")
1111+
_tl = _progress.execution_timeline_lines(response.execution_trace)
1112+
if not _tl:
1113+
self.print("(no timed steps recorded for this run)")
1114+
for _style, _text in _tl:
1115+
if self.console:
1116+
self.console.print(f"[{_style}]{_text}[/{_style}]")
1117+
else:
1118+
print(_text)
1119+
1120+
# Display the step trace (tool reasoning + per-step timing).
1121+
if (_explain or _trace) and response.execution_trace:
1122+
self.print_header("Execution Trace")
11071123
_lines = _progress.execution_trace_lines(response.execution_trace)
11081124
if not _lines:
11091125
self.print("(no detailed steps recorded for this run)")
@@ -1113,8 +1129,18 @@ def run_agent(self, args):
11131129
else:
11141130
print(_text)
11151131

1132+
# On a multi-step run without an explicit trace flag, point
1133+
# the user at the timeline rather than leaving it hidden.
1134+
elif not quiet and not _explain and int(getattr(response, "tool_calls", 0) or 0) >= 1:
1135+
_steps = int(getattr(response, "tool_calls", 0) or 0)
1136+
_hint = f"{_steps} tool step{'s' if _steps != 1 else ''} — run with --trace to see the timeline"
1137+
if self.console:
1138+
self.console.print(f"[effgen.muted]{_hint}[/effgen.muted]")
1139+
else:
1140+
print(_hint)
1141+
11161142
# Display execution statistics
1117-
if getattr(args, 'verbose', False) or getattr(args, 'explain', False):
1143+
if getattr(args, 'verbose', False) or _explain or _trace:
11181144
self.print_header("Execution Statistics")
11191145
stats_table = self._create_stats_table({
11201146
"Mode": response.mode.value,
@@ -2947,6 +2973,8 @@ def create_parser():
29472973
)
29482974
run_parser.add_argument('--explain', action='store_true',
29492975
help='Show why the agent chose each tool')
2976+
run_parser.add_argument('--trace', action='store_true',
2977+
help='Show a step-by-step timeline with per-step durations')
29502978
run_parser.add_argument('--checkpoint-dir', help='Directory to write agent checkpoints')
29512979
run_parser.add_argument('--checkpoint-interval', type=int, default=0,
29522980
help='Checkpoint every N iterations (requires --checkpoint-dir)')
@@ -3254,11 +3282,20 @@ def create_parser():
32543282
'workflow entry node(s) (alternative to --input)')
32553283
workflow_run.add_argument('--json', dest='output_json', action='store_true',
32563284
help='Emit the workflow result as JSON to stdout (for CI gating)')
3285+
workflow_run.add_argument('--diagram', action='store_true',
3286+
help='Draw the workflow as a dependency graph (nodes by '
3287+
'level, edges, per-node status/duration/cost)')
3288+
workflow_run.add_argument('-q', '--quiet', action='store_true', default=argparse.SUPPRESS,
3289+
help='Quiet output (errors only); --json still emits to stdout')
32573290

32583291
workflow_validate = workflow_subparsers.add_parser('validate', help='Validate a workflow YAML file')
32593292
workflow_validate.add_argument('file', help='Path to workflow YAML file')
32603293
workflow_validate.add_argument('--json', dest='output_json', action='store_true',
32613294
help='Emit the validation result as JSON to stdout')
3295+
workflow_validate.add_argument('--diagram', action='store_true',
3296+
help='Draw the workflow dependency graph (nodes by level, edges)')
3297+
workflow_validate.add_argument('-q', '--quiet', action='store_true', default=argparse.SUPPRESS,
3298+
help='Quiet output (errors only); --json still emits to stdout')
32623299

32633300
# Batch command
32643301
batch_parser = subparsers.add_parser(
@@ -4129,6 +4166,24 @@ def _handle_workflow_command(args, cli) -> int:
41294166
json_mode = getattr(args, 'output_json', False)
41304167
if json_mode:
41314168
cli._human_to_stderr = True
4169+
show_diagram = getattr(args, 'diagram', False)
4170+
4171+
def _print_diagram(dag, node_results=None):
4172+
from effgen.ui.workflow_viz import workflow_diagram_lines
4173+
order = dag.topological_order()
4174+
levels = dag._compute_levels(order)
4175+
lines = workflow_diagram_lines(
4176+
dag.name,
4177+
[n.id for n in dag.nodes],
4178+
[e.to_dict() for e in dag.edges],
4179+
levels,
4180+
node_results=node_results,
4181+
)
4182+
for style, text in lines:
4183+
if cli.console and style:
4184+
cli.console.print(f"[{style}]{text}[/{style}]")
4185+
else:
4186+
cli.print(text)
41324187

41334188
if wf_cmd == 'validate':
41344189
try:
@@ -4147,6 +4202,9 @@ def _handle_workflow_command(args, cli) -> int:
41474202
cli.print(f" Nodes: {len(dag.nodes)}")
41484203
cli.print(f" Edges: {len(dag.edges)}")
41494204
cli.print(f" Execution order: {' -> '.join(order)}")
4205+
if show_diagram:
4206+
cli.print("")
4207+
_print_diagram(dag)
41504208
return 0
41514209
except Exception as e:
41524210
if json_mode:
@@ -4197,8 +4255,10 @@ def _agent_factory(nd):
41974255
)
41984256
return Agent(config)
41994257

4258+
quiet = getattr(args, 'quiet', False)
42004259
dag = WorkflowDAG.from_yaml(args.file, agent_factory=_agent_factory)
4201-
cli.print(f"Running workflow '{dag.name}' ({len(dag.nodes)} nodes)...")
4260+
if not quiet:
4261+
cli.print(f"Running workflow '{dag.name}' ({len(dag.nodes)} nodes)...")
42024262

42034263
# Per-node ``task:`` strings declared in the YAML become each node's
42044264
# default input (so `effgen workflow run workflow.yaml` works with no
@@ -4238,17 +4298,23 @@ def _agent_factory(nd):
42384298
print(json.dumps(result.to_dict(), indent=2, default=str, ensure_ascii=False))
42394299
return 0 if result.success else 1
42404300

4241-
cli.print(f"\nWorkflow {'succeeded' if result.success else 'FAILED'} "
4242-
f"in {result.execution_time:.2f}s")
4243-
for nr in result.node_results:
4244-
status = nr['status']
4245-
cli.print(f" [{status:>9s}] {nr['id']} ({nr['execution_time']:.2f}s)")
4246-
4247-
if result.success:
4248-
# Show final outputs
4249-
cli.print("\nOutputs:")
4250-
for key, val in result.outputs.items():
4251-
cli.print(f" {key}: {str(val)[:200]}")
4301+
if not quiet:
4302+
cli.print(f"\nWorkflow {'succeeded' if result.success else 'FAILED'} "
4303+
f"in {result.execution_time:.2f}s")
4304+
4305+
if show_diagram:
4306+
cli.print("")
4307+
_print_diagram(dag, node_results=result.node_results)
4308+
else:
4309+
for nr in result.node_results:
4310+
status = nr['status']
4311+
cli.print(f" [{status:>9s}] {nr['id']} ({nr['execution_time']:.2f}s)")
4312+
4313+
if result.success:
4314+
# Show final outputs
4315+
cli.print("\nOutputs:")
4316+
for key, val in result.outputs.items():
4317+
cli.print(f" {key}: {str(val)[:200]}")
42524318

42534319
return 0 if result.success else 1
42544320

effgen/cli/progress.py

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -417,18 +417,50 @@ def format_tool_call(name: str, tool_input: Any, limit: int = 72) -> str:
417417
return f"{name}({inner})"
418418

419419

420+
def _fmt_duration(seconds: float) -> str:
421+
"""Human duration: ``820ms`` under a second, ``14.4s`` / ``1m03s`` above."""
422+
if seconds < 1.0:
423+
return f"{seconds * 1000:.0f}ms"
424+
if seconds < 60.0:
425+
return f"{seconds:.1f}s"
426+
m, s = divmod(int(round(seconds)), 60)
427+
return f"{m}m{s:02d}s"
428+
429+
430+
def _event_gaps(trace: list[dict[str, Any]]) -> list[float]:
431+
"""Wall-clock seconds elapsed *before* each event (gap from the previous one).
432+
433+
The gap before a ``tool_call_start`` is the model's think time for that
434+
step — the time the flat trace throws away. The first event's gap is 0.
435+
"""
436+
gaps: list[float] = []
437+
prev: float | None = None
438+
for ev in trace:
439+
ts = ev.get("timestamp")
440+
if isinstance(ts, int | float) and prev is not None:
441+
gaps.append(max(0.0, float(ts) - prev))
442+
else:
443+
gaps.append(0.0)
444+
if isinstance(ts, int | float):
445+
prev = float(ts)
446+
return gaps
447+
448+
420449
def execution_trace_lines(trace: list[dict[str, Any]] | None) -> list[tuple[str, str]]:
421450
"""Turn an :class:`ExecutionTracker` event trace into readable ``(style, text)``.
422451
423452
The agent's ``execution_trace`` is a list of event dicts (``type``,
424453
``message``, ``data``) — not a ReAct ``thought/action/observation`` shape —
425454
so a plain ``step.get("thought")`` renders blank. This formatter walks the
426455
events and produces one human line each (reasoning, tool call + result,
427-
delegation, …), shared by ``effgen run --explain`` and chat's ``/trace`` so
428-
they agree.
456+
delegation, …), annotating each step with the wall-clock time it took, and
457+
is shared by ``effgen run --explain`` and chat's ``/trace`` so they agree.
429458
"""
459+
events = list(trace or [])
460+
gaps = _event_gaps(events)
461+
# Pair each tool_call_start with its terminal event to time the tool itself.
430462
out: list[tuple[str, str]] = []
431-
for ev in trace or []:
463+
for i, ev in enumerate(events):
432464
etype = str(ev.get("type", "") or "")
433465
msg = str(ev.get("message", "") or "")
434466
data = ev.get("data") or {}
@@ -441,10 +473,16 @@ def execution_trace_lines(trace: list[dict[str, Any]] | None) -> list[tuple[str,
441473
detail = f"🔧 {format_tool_call(name, tool_input)}"
442474
else:
443475
detail = f"🔧 {name}"
476+
# Attribute the model's think time (the gap into this call) so the
477+
# step reads with the time it cost, not as if it were instant.
478+
if gaps[i] >= 0.1:
479+
detail += f" ⏱ {_fmt_duration(gaps[i])}"
444480
out.append(("green", detail))
445481
elif etype == "tool_call_complete":
446482
result = data.get("result", data.get("output", ""))
447-
out.append(("dim", f" ✓ {_truncate(result, 120)}" if result else " ✓ done"))
483+
exec_s = gaps[i] if gaps[i] >= 0.5 else 0.0
484+
suffix = f" ({_fmt_duration(exec_s)})" if exec_s else ""
485+
out.append(("dim", f" ✓ {_truncate(result, 120)}{suffix}" if result else f" ✓ done{suffix}"))
448486
elif etype == "tool_call_failed":
449487
err = data.get("error", msg)
450488
out.append(("red", f" ✗ {_truncate(err, 120)}"))
@@ -459,6 +497,52 @@ def execution_trace_lines(trace: list[dict[str, Any]] | None) -> list[tuple[str,
459497
return out
460498

461499

500+
def execution_timeline_lines(trace: list[dict[str, Any]] | None) -> list[tuple[str, str]]:
501+
"""A compact per-step timeline: each step with a proportional bar + duration.
502+
503+
Collapses the event stream into one line per meaningful step (a tool call,
504+
reasoning, or delegation), sizes a bar by the step's wall-clock share, and
505+
labels it with the elapsed time — so a run's slow steps are visible at a
506+
glance. Falls back to an empty list when there is nothing timed to show.
507+
"""
508+
events = list(trace or [])
509+
if not events:
510+
return []
511+
gaps = _event_gaps(events)
512+
513+
# Build steps: a tool call is timed by the think-gap leading into it; a bare
514+
# reasoning or delegation step is timed by its own leading gap.
515+
steps: list[tuple[str, str, float]] = [] # (style, label, seconds)
516+
for i, ev in enumerate(events):
517+
etype = str(ev.get("type", "") or "")
518+
data = ev.get("data") or {}
519+
if etype == "tool_call_start":
520+
name = data.get("tool_name", "tool")
521+
tool_input = data.get("tool_input", data.get("input", ""))
522+
label = format_tool_call(name, tool_input) if tool_input else name
523+
steps.append(("green", f"🔧 {label}", gaps[i]))
524+
elif etype == "sub_agent_start":
525+
name = data.get("agent_name") or ev.get("agent_id") or "sub-agent"
526+
steps.append(("magenta", f"👥 {name}", gaps[i]))
527+
elif etype == "task_decomposition":
528+
steps.append(("yellow", "🧩 planning", gaps[i]))
529+
if not steps:
530+
return []
531+
532+
longest = max((s for _, _, s in steps), default=0.0) or 1.0
533+
bar_w = 20
534+
label_w = min(40, max(len(lbl) for _, lbl, _ in steps))
535+
out: list[tuple[str, str]] = []
536+
for style, label, secs in steps:
537+
filled = int(round((secs / longest) * bar_w)) if longest > 0 else 0
538+
bar = "█" * filled + "·" * (bar_w - filled)
539+
lbl = label if len(label) <= label_w else label[: label_w - 1] + "…"
540+
out.append((style, f"{lbl:<{label_w}} {bar} {_fmt_duration(secs):>7}"))
541+
total = sum(s for _, _, s in steps)
542+
out.append(("dim", f"{'total':<{label_w}} {'':<{bar_w}} {_fmt_duration(total):>7}"))
543+
return out
544+
545+
462546
# ---------------------------------------------------------------------------
463547
# Progress bars for the long-running, countable commands
464548
# ---------------------------------------------------------------------------

effgen/core/agent.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1320,7 +1320,6 @@ def run(self,
13201320
# Add execution metadata
13211321
response.execution_time = time.time() - start_time
13221322
response.execution_trace = self.execution_tracker.get_trace()
1323-
response.execution_tree = self.execution_tracker.generate_execution_tree()
13241323
response.metadata["run_id"] = run_id
13251324
if input_redaction is not None:
13261325
response.metadata["input_redaction"] = input_redaction
@@ -1345,6 +1344,11 @@ def run(self,
13451344
}
13461345
))
13471346

1347+
# Build the execution tree after completion is tracked so its
1348+
# root carries a terminal status and a real duration (not a
1349+
# still-"running" snapshot measured at serialize time).
1350+
response.execution_tree = self.execution_tracker.generate_execution_tree()
1351+
13481352
# Metrics: record latency and tokens
13491353
prom_metrics.response_latency.observe(response.execution_time, labels=labels)
13501354
if response.tokens_used:
@@ -1474,6 +1478,7 @@ def run(self,
14741478
success=False,
14751479
execution_time=time.time() - start_time,
14761480
execution_trace=self.execution_tracker.get_trace(),
1481+
execution_tree=self.execution_tracker.generate_execution_tree(),
14771482
metadata={"reason": "run_failed", "error": detail, "run_id": run_id}
14781483
)
14791484
self._record_dashboard_run(response, error=redacted_msg)

0 commit comments

Comments
 (0)