From b32467c6e3918e9589f72628c1a1852af5dbed3a Mon Sep 17 00:00:00 2001 From: Cristian Pufu Date: Mon, 9 Feb 2026 13:49:21 +0200 Subject: [PATCH] fix: route forward skip-layer edges around intermediate boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forward edges that skip layers (e.g. parse_agent_output → __end__) were drawing straight vertical lines through intermediate boxes, corrupting node text. Add right-side corridor routing for colliding forward edges, matching the existing backward edge corridor pattern. - Add _forward_skip_corridors() to detect collisions and assign corridors - Add _draw_forward_corridor() with direct array access for performance - Account for label width in corridor margin calculation - Add function-agent sample, screenshot, and benchmark entry - Add tests for skip-layer collision avoidance Co-Authored-By: Claude Opus 4.6 --- README.md | 17 +-- pyproject.toml | 2 +- samples/function-agent/graph.json | 104 +++++++++++++++ screenshots/function-agent.png | Bin 0 -> 45898 bytes scripts/generate_screenshots.py | 5 + src/graphtty/renderer.py | 205 +++++++++++++++++++++++++++++- tests/test_renderer.py | 60 +++++++++ uv.lock | 2 +- 8 files changed, 383 insertions(+), 12 deletions(-) create mode 100644 samples/function-agent/graph.json create mode 100644 screenshots/function-agent.png diff --git a/README.md b/README.md index 7d5dfda..95010bf 100644 --- a/README.md +++ b/README.md @@ -125,18 +125,19 @@ graphtty reads a simple JSON format: ## Benchmarks -graphtty uses a custom Sugiyama-style layout engine and optimized canvas operations for fast rendering. Benchmarks across all 8 sample graphs (50 iterations each, Python 3.11): +graphtty uses a custom Sugiyama-style layout engine and optimized canvas operations for fast rendering. Benchmarks across all 9 sample graphs (50 iterations each, Python 3.11): | Sample | Avg (ms) | Ops/sec | |---|---:|---:| | react-agent (4 nodes) | 0.15 | 6,522 | -| deep-agent (7 nodes) | 0.32 | 3,149 | -| workflow-agent (11 nodes) | 0.43 | 2,347 | -| world-map (15 nodes) | 0.53 | 1,887 | -| rag-pipeline (10 nodes) | 0.71 | 1,419 | -| supervisor-agent (7+subs) | 0.72 | 1,395 | -| etl-pipeline (12 nodes) | 0.78 | 1,282 | -| code-review (8+subs) | 1.13 | 885 | +| deep-agent (7 nodes) | 0.31 | 3,161 | +| function-agent (8 nodes) | 0.36 | 2,806 | +| workflow-agent (11 nodes) | 0.42 | 2,370 | +| world-map (15 nodes) | 0.55 | 1,818 | +| supervisor-agent (7+subs) | 0.71 | 1,406 | +| rag-pipeline (10 nodes) | 0.74 | 1,347 | +| etl-pipeline (12 nodes) | 0.84 | 1,193 | +| code-review (8+subs) | 1.16 | 864 | Run `python scripts/benchmark.py` to reproduce on your machine. diff --git a/pyproject.toml b/pyproject.toml index 4b41ac4..31c6570 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "graphtty" -version = "0.1.3" +version = "0.1.4" description = "Turn any directed graph into colored ASCII art for your terminal" readme = "README.md" license = "MIT" diff --git a/samples/function-agent/graph.json b/samples/function-agent/graph.json new file mode 100644 index 0000000..9c07dc8 --- /dev/null +++ b/samples/function-agent/graph.json @@ -0,0 +1,104 @@ +{ + "nodes": [ + { + "id": "__start__", + "name": "__start__", + "type": "__start__", + "subgraph": null + }, + { + "id": "aggregate_tool_results", + "name": "aggregate_tool_results", + "type": "tool", + "subgraph": null + }, + { + "id": "call_tool", + "name": "call_tool", + "type": "tool", + "subgraph": null + }, + { + "id": "init_run", + "name": "init_run", + "type": "node", + "subgraph": null + }, + { + "id": "parse_agent_output", + "name": "parse_agent_output", + "type": "node", + "subgraph": null + }, + { + "id": "run_agent_step", + "name": "run_agent_step", + "type": "model", + "subgraph": null + }, + { + "id": "setup_agent", + "name": "setup_agent", + "type": "node", + "subgraph": null + }, + { + "id": "__end__", + "name": "__end__", + "type": "__end__", + "subgraph": null + } + ], + "edges": [ + { + "source": "aggregate_tool_results", + "target": "setup_agent", + "label": "AgentInput" + }, + { + "source": "aggregate_tool_results", + "target": "__end__", + "label": "StopEvent" + }, + { + "source": "call_tool", + "target": "aggregate_tool_results", + "label": "ToolCallResult" + }, + { + "source": "__start__", + "target": "init_run", + "label": "AgentWorkflowStartEvent" + }, + { + "source": "init_run", + "target": "setup_agent", + "label": "AgentInput" + }, + { + "source": "parse_agent_output", + "target": "__end__", + "label": "StopEvent" + }, + { + "source": "parse_agent_output", + "target": "setup_agent", + "label": "AgentInput" + }, + { + "source": "parse_agent_output", + "target": "call_tool", + "label": "ToolCall" + }, + { + "source": "run_agent_step", + "target": "parse_agent_output", + "label": "AgentOutput" + }, + { + "source": "setup_agent", + "target": "run_agent_step", + "label": "AgentSetup" + } + ] +} diff --git a/screenshots/function-agent.png b/screenshots/function-agent.png new file mode 100644 index 0000000000000000000000000000000000000000..329565cef88cfa11765d14359fef69489f495d1a GIT binary patch literal 45898 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

1Y9K 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 tuple[dict[int, int], int]: + """Compute route_x corridors for forward edges that skip layers. + + Forward edges whose straight vertical path passes through an intermediate + box are re-routed through a right-side corridor, similar to backward edges. + + Returns ``(corridor_map, margin)`` — same shape as backward corridors. + """ + all_boxes = list(boxes.values()) + global_max_right = max((b.x + b.w for b in all_boxes), default=0) + corridor_map: dict[int, int] = {} + slot = 0 + for idx, edge in enumerate(edges): + src = boxes.get(edge.source) + tgt = boxes.get(edge.target) + if src is None or tgt is None: + continue + if src.bottom >= tgt.top: + continue # not a forward edge + + src_cx = src.cx + tgt_cx = tgt.cx + if abs(src_cx - tgt_cx) > _STRAIGHT_TOLERANCE: + continue # Z-shape edges unlikely to collide with intermediate boxes + + edge_x = tgt_cx # straight edges align to target centre + src_bottom = src.bottom + tgt_top = tgt.top + # Check if vertical at edge_x passes through any intermediate box + collides = False + local_max_right = 0 + for b in all_boxes: + if b is src or b is tgt: + continue + # Box must be vertically between src and tgt + b_bottom = b.y + b.h + if b_bottom <= src_bottom or b.y >= tgt_top: + continue + b_right = b.x + b.w + # Box must horizontally contain the edge x + if b.x <= edge_x < b_right: + collides = True + # Track rightmost box in the vertical span for corridor placement + if b_right > local_max_right: + local_max_right = b_right + + if collides: + route_x = max(local_max_right + 3, min_route_x) + slot * 3 + corridor_map[idx] = route_x + slot += 1 + + if not corridor_map: + return corridor_map, 0 + + max_route_x = max(corridor_map.values()) + # Account for label width on forward corridors + max_label_w = 0 + for idx in corridor_map: + lbl = edges[idx].label + if lbl: + lbl_w = len(lbl) + 2 # +2 gap + if lbl_w > max_label_w: + max_label_w = lbl_w + margin = max(0, max_route_x + max_label_w - global_max_right + 3) return corridor_map, margin @@ -365,6 +449,8 @@ def _draw_edge( ch, edge_color=color, label_color=label_color, + route_x=route_x, + all_boxes=all_boxes, ) elif tgt.bottom < src.top: _draw_backward_edge( @@ -399,8 +485,24 @@ def _draw_forward_edge( *, edge_color: str | None = None, label_color: str | None = None, + route_x: int | None = None, + all_boxes: dict[str, Box] | None = None, ) -> None: """Source is above target — connect bottom-centre to top-centre.""" + if route_x is not None: + _draw_forward_corridor( + canvas, + src, + tgt, + label, + ch, + route_x=route_x, + edge_color=edge_color, + label_color=label_color, + all_boxes=all_boxes, + ) + return + src_cx = src.cx tgt_cx = tgt.cx start_y = src.bottom @@ -473,6 +575,105 @@ def _draw_forward_edge( canvas.put(tgt_cx, arrow_y, ch["arrow_down"], edge_color) +def _draw_forward_corridor( + canvas: Canvas, + src: Box, + tgt: Box, + label: str | None, + ch: dict[str, str], + *, + route_x: int, + edge_color: str | None = None, + label_color: str | None = None, + all_boxes: dict[str, Box] | None = None, +) -> None: + """Draw a forward edge routed through a right-side corridor. + + Used when the straight vertical path would collide with intermediate boxes. + Routes: src ┬ → down → └──┐ corridor │ ┘──┌ → down → ▼ tgt + """ + src_cx = src.cx + tgt_cx = tgt.cx + start_y = src.bottom + end_y = tgt.top + + top_horiz_y = start_y + 2 + bot_horiz_y = end_y - 2 + # Safety clamp + if top_horiz_y >= bot_horiz_y: + mid = (start_y + end_y) // 2 + top_horiz_y = mid + bot_horiz_y = mid + 1 + + arrow_y = end_y - 1 if end_y - 1 > start_y else end_y + + # Pre-compute occupied intervals for the two horizontal rows + top_intervals: list[tuple[int, int]] = [] + bot_intervals: list[tuple[int, int]] = [] + if all_boxes: + top_intervals = _x_intervals_at_y(top_horiz_y, all_boxes, src, tgt) + bot_intervals = _x_intervals_at_y(bot_horiz_y, all_boxes, src, tgt) + + # Direct array access for performance + rows = canvas._rows + colors = canvas._colors + ch_v = ch["v"] + ch_h = ch["h"] + + # 1. Junction at source bottom + canvas.put(src_cx, start_y, ch["jt"], edge_color) + + # 2. Vertical from source down to top_horiz_y + for y in range(start_y + 1, top_horiz_y): + rows[y][src_cx] = ch_v + if edge_color is not None: + colors[y][src_cx] = edge_color + + # 3. Corner └ at (src_cx, top_horiz_y), horizontal to route_x, corner ┐ + canvas.put(src_cx, top_horiz_y, ch["bl"], edge_color) + top_row = rows[top_horiz_y] + top_color_row = colors[top_horiz_y] + for x in range(src_cx + 1, route_x): + if top_intervals and _x_in_intervals(x, top_intervals): + continue + top_row[x] = ch_h + if edge_color is not None: + top_color_row[x] = edge_color + canvas.put(route_x, top_horiz_y, ch["tr"], edge_color) + + # 4. Vertical down corridor + for y in range(top_horiz_y + 1, bot_horiz_y): + rows[y][route_x] = ch_v + if edge_color is not None: + colors[y][route_x] = edge_color + + # 5. Corner ┘ at (route_x, bot_horiz_y), horizontal back to tgt_cx, corner ┌ + canvas.put(route_x, bot_horiz_y, ch["br"], edge_color) + bot_row = rows[bot_horiz_y] + bot_color_row = colors[bot_horiz_y] + for x in range(tgt_cx + 1, route_x): + if bot_intervals and _x_in_intervals(x, bot_intervals): + continue + bot_row[x] = ch_h + if edge_color is not None: + bot_color_row[x] = edge_color + canvas.put(tgt_cx, bot_horiz_y, ch["tl"], edge_color) + + # 6. Vertical down to arrow + for y in range(bot_horiz_y + 1, arrow_y): + rows[y][tgt_cx] = ch_v + if edge_color is not None: + colors[y][tgt_cx] = edge_color + + # 7. Arrow above target box + canvas.put(tgt_cx, arrow_y, ch["arrow_down"], edge_color) + + # 8. Label alongside corridor vertical + if label: + label_y = (top_horiz_y + bot_horiz_y) // 2 + canvas.puts(route_x + 2, label_y, label, label_color) + + def _x_intervals_at_y( y: int, boxes: dict[str, Box], src: Box, tgt: Box ) -> list[tuple[int, int]]: diff --git a/tests/test_renderer.py b/tests/test_renderer.py index f778af1..ba4586c 100644 --- a/tests/test_renderer.py +++ b/tests/test_renderer.py @@ -343,3 +343,63 @@ def test_nested_subgraphs(self): assert "Top" in result assert "Mid" in result assert "Deep" in result + + +class TestForwardSkipLayerEdges: + def test_skip_edge_does_not_overwrite_intermediate_nodes(self): + """A→D skip edge should not corrupt intermediate node B or C text.""" + g = AsciiGraph( + nodes=[ + _node("a", "Alpha", "entry"), + _node("b", "Bravo", "action"), + _node("c", "Charlie", "action"), + _node("d", "Delta", "exit"), + ], + edges=[ + _edge("a", "b"), + _edge("b", "c"), + _edge("c", "d"), + _edge("a", "d"), # skip edge: jumps over B and C + ], + ) + result = render(g) + # All node names must remain intact (not overwritten by edge chars) + assert "Alpha" in result + assert "Bravo" in result + assert "Charlie" in result + assert "Delta" in result + + def test_skip_edge_with_label(self): + """Skip edge label should be rendered.""" + g = AsciiGraph( + nodes=[ + _node("a", "Start", "entry"), + _node("b", "Mid", "action"), + _node("c", "End", "exit"), + ], + edges=[ + _edge("a", "b"), + _edge("b", "c"), + _edge("a", "c", label="skip"), # skip edge with label + ], + ) + result = render(g) + assert "Start" in result + assert "Mid" in result + assert "End" in result + assert "skip" in result + + def test_function_agent_sample(self): + """The function-agent sample should render all node names intact.""" + import json + from pathlib import Path + + sample = ( + Path(__file__).parent.parent / "samples" / "function-agent" / "graph.json" + ) + if not sample.exists(): + return # skip if sample not available + data = json.loads(sample.read_text(encoding="utf-8")) + result = render(data) + for node in data["nodes"]: + assert node["name"] in result, f"Node '{node['name']}' missing from render" diff --git a/uv.lock b/uv.lock index f29c9a5..41f566c 100644 --- a/uv.lock +++ b/uv.lock @@ -105,7 +105,7 @@ toml = [ [[package]] name = "graphtty" -version = "0.1.3" +version = "0.1.4" source = { editable = "." } [package.dev-dependencies]