workgraph 0.3.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- workgraph/__init__.py +1 -0
- workgraph/claude.py +106 -0
- workgraph/cli.py +379 -0
- workgraph/codex.py +232 -0
- workgraph/definitions/agents/wg_code-review.md +14 -0
- workgraph/definitions/agents/wg_design.md +57 -0
- workgraph/definitions/agents/wg_implement.md +39 -0
- workgraph/definitions/agents/wg_overengineering-review.md +13 -0
- workgraph/definitions/agents/wg_plan.md +79 -0
- workgraph/definitions/agents/wg_pr.md +33 -0
- workgraph/definitions/agents/wg_summarize-review.md +10 -0
- workgraph/definitions/workflows/wg.toml +101 -0
- workgraph/definitions/workflows/wg_codex.toml +106 -0
- workgraph/graph.py +360 -0
- workgraph/harness.py +112 -0
- workgraph/run.py +942 -0
- workgraph/show.py +676 -0
- workgraph/workflow.py +263 -0
- workgraph-0.3.3.dist-info/METADATA +139 -0
- workgraph-0.3.3.dist-info/RECORD +23 -0
- workgraph-0.3.3.dist-info/WHEEL +4 -0
- workgraph-0.3.3.dist-info/entry_points.txt +3 -0
- workgraph-0.3.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
start = "design"
|
|
2
|
+
|
|
3
|
+
[defaults]
|
|
4
|
+
harness = "claude"
|
|
5
|
+
model = "claude-fable-5-1"
|
|
6
|
+
effort = "high"
|
|
7
|
+
|
|
8
|
+
[nodes.design]
|
|
9
|
+
agent = "wg_design"
|
|
10
|
+
effort = "xhigh"
|
|
11
|
+
outcomes = ["done"]
|
|
12
|
+
|
|
13
|
+
[nodes.design.transitions]
|
|
14
|
+
done = "approve-design"
|
|
15
|
+
|
|
16
|
+
[nodes.approve-design]
|
|
17
|
+
gate = "Plan from this design?"
|
|
18
|
+
|
|
19
|
+
[nodes.approve-design.transitions]
|
|
20
|
+
accept = "plan"
|
|
21
|
+
reject = "design"
|
|
22
|
+
|
|
23
|
+
[nodes.plan]
|
|
24
|
+
agent = "wg_plan"
|
|
25
|
+
effort = "xhigh"
|
|
26
|
+
outcomes = ["done"]
|
|
27
|
+
|
|
28
|
+
[nodes.plan.transitions]
|
|
29
|
+
done = "approve-plan"
|
|
30
|
+
|
|
31
|
+
[nodes.approve-plan]
|
|
32
|
+
gate = "Implement this plan?"
|
|
33
|
+
|
|
34
|
+
[nodes.approve-plan.transitions]
|
|
35
|
+
accept = "implement"
|
|
36
|
+
reject = "plan"
|
|
37
|
+
|
|
38
|
+
[nodes.implement]
|
|
39
|
+
agent = "wg_implement"
|
|
40
|
+
harness = "codex"
|
|
41
|
+
model = "gpt-5.6-sol"
|
|
42
|
+
effort = "xhigh"
|
|
43
|
+
sandbox = "danger-full-access"
|
|
44
|
+
outcomes = ["done"]
|
|
45
|
+
|
|
46
|
+
[nodes.implement.transitions]
|
|
47
|
+
done = "test"
|
|
48
|
+
|
|
49
|
+
[nodes.test]
|
|
50
|
+
command = "sh -c 'uv run ruff check && uv run ruff format --check && uv run mypy && uv run pytest'"
|
|
51
|
+
|
|
52
|
+
[nodes.test.limits]
|
|
53
|
+
visits = 5
|
|
54
|
+
reset = "pass"
|
|
55
|
+
|
|
56
|
+
[nodes.test.transitions]
|
|
57
|
+
pass = "review"
|
|
58
|
+
fail = "implement"
|
|
59
|
+
|
|
60
|
+
[nodes.review]
|
|
61
|
+
map = ["code-review", "overengineering-review"]
|
|
62
|
+
resolve = "all"
|
|
63
|
+
|
|
64
|
+
[nodes.review.transitions]
|
|
65
|
+
pass = "pr"
|
|
66
|
+
fail = "review-loop"
|
|
67
|
+
|
|
68
|
+
# Holds the review limit so the LIMIT diversion carries the review findings.
|
|
69
|
+
[nodes.review-loop]
|
|
70
|
+
command = "true"
|
|
71
|
+
|
|
72
|
+
[nodes.review-loop.limits]
|
|
73
|
+
visits = 2
|
|
74
|
+
|
|
75
|
+
[nodes.review-loop.transitions]
|
|
76
|
+
pass = "implement"
|
|
77
|
+
fail = "implement"
|
|
78
|
+
LIMIT = "summary"
|
|
79
|
+
|
|
80
|
+
[nodes.code-review]
|
|
81
|
+
agent = "wg_code-review"
|
|
82
|
+
outcomes = ["pass", "fail"]
|
|
83
|
+
|
|
84
|
+
[nodes.overengineering-review]
|
|
85
|
+
agent = "wg_overengineering-review"
|
|
86
|
+
outcomes = ["pass", "fail"]
|
|
87
|
+
|
|
88
|
+
[nodes.summary]
|
|
89
|
+
agent = "wg_summarize-review"
|
|
90
|
+
harness = "codex"
|
|
91
|
+
model = "gpt-5.6-sol"
|
|
92
|
+
outcomes = ["done"]
|
|
93
|
+
|
|
94
|
+
[nodes.summary.transitions]
|
|
95
|
+
done = "pr"
|
|
96
|
+
|
|
97
|
+
[nodes.pr]
|
|
98
|
+
agent = "wg_pr"
|
|
99
|
+
harness = "codex"
|
|
100
|
+
model = "gpt-5.6-luna"
|
|
101
|
+
effort = "max"
|
|
102
|
+
sandbox = "danger-full-access"
|
|
103
|
+
outcomes = ["done"]
|
|
104
|
+
|
|
105
|
+
[nodes.pr.transitions]
|
|
106
|
+
done = "END"
|
workgraph/graph.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""Draw the run's path as a vertical chain: show-journal --graph."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
import time
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from itertools import zip_longest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from workgraph import show
|
|
14
|
+
from workgraph.run import GREY, format_duration, parse_node_name, read_state
|
|
15
|
+
from workgraph.show import DECISION_STYLE, Event, _RunRecord, format_resumed_suffix
|
|
16
|
+
from workgraph.workflow import END
|
|
17
|
+
|
|
18
|
+
# Seconds between two redraws under follow; the journal poll keeps show.POLL_INTERVAL.
|
|
19
|
+
REDRAW_INTERVAL = 0.1
|
|
20
|
+
# Seconds per sine fade of the current glyph, from #606060 to white and back.
|
|
21
|
+
PULSE_PERIOD = 2.0
|
|
22
|
+
|
|
23
|
+
GLYPH_CURRENT = "◆"
|
|
24
|
+
GLYPH_PAST = "◇"
|
|
25
|
+
GLYPH_RESUMED = "↻"
|
|
26
|
+
GLYPH_PASS = "✓"
|
|
27
|
+
GLYPH_FAIL = "✗"
|
|
28
|
+
GLYPH_GATE = "⬡"
|
|
29
|
+
GLYPH_LIMIT = "┆"
|
|
30
|
+
GLYPH_WARN = "⚠"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class _NodeRun:
|
|
35
|
+
"""One node run read from its start and end events; fanned_out_runs holds its fan-out."""
|
|
36
|
+
|
|
37
|
+
node_run_name: str
|
|
38
|
+
node_name: str
|
|
39
|
+
start_time: datetime
|
|
40
|
+
end_time: datetime | None = None
|
|
41
|
+
outcome: str | None = None
|
|
42
|
+
cost: float = 0.0
|
|
43
|
+
resumed_session: bool = False
|
|
44
|
+
fell_back: bool = False
|
|
45
|
+
fanned_out_runs: list["_NodeRun"] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# The chain in journal order: node runs, and the limit, stop, and resume events.
|
|
49
|
+
ChainEntry = _NodeRun | Event
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class _Row:
|
|
54
|
+
"""One line of the chain: the left column, and a fan-out part hanging to the right."""
|
|
55
|
+
|
|
56
|
+
left: Text
|
|
57
|
+
right: Text = field(default_factory=Text)
|
|
58
|
+
# The right part continues the chain, reached with a `─` fill.
|
|
59
|
+
connects: bool = False
|
|
60
|
+
# A stop row carries no right part, so it may run past the column.
|
|
61
|
+
overruns: bool = False
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def show_graph(directory: Path) -> list[Text]:
|
|
65
|
+
"""Render the run's path once: the header line, then the chain."""
|
|
66
|
+
return _render_graph(_RunRecord(directory), pulse=None)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def follow_graph(directory: Path, until_end: bool) -> Iterator[list[Text]]:
|
|
70
|
+
"""Yield one frame per redraw until the run stops; the last frame shows the final state.
|
|
71
|
+
|
|
72
|
+
The current glyph fades with the frame count. The journal poll stays at show.POLL_INTERVAL,
|
|
73
|
+
one poll every POLL_INTERVAL / REDRAW_INTERVAL frames.
|
|
74
|
+
"""
|
|
75
|
+
record = _RunRecord(directory)
|
|
76
|
+
frame_count = 0
|
|
77
|
+
while True:
|
|
78
|
+
pulse = frame_count * REDRAW_INTERVAL % PULSE_PERIOD / PULSE_PERIOD
|
|
79
|
+
yield _render_graph(record, pulse)
|
|
80
|
+
stop_event = record.stop_event
|
|
81
|
+
if stop_event and (stop_event["reason"] == "end" or not until_end):
|
|
82
|
+
return
|
|
83
|
+
record.check_interrupted()
|
|
84
|
+
time.sleep(REDRAW_INTERVAL)
|
|
85
|
+
frame_count += 1
|
|
86
|
+
if frame_count % round(show.POLL_INTERVAL / REDRAW_INTERVAL) == 0:
|
|
87
|
+
record.read_events()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _render_graph(record: _RunRecord, pulse: float | None) -> list[Text]:
|
|
91
|
+
chain = _build_chain(record.events)
|
|
92
|
+
now = record.now
|
|
93
|
+
return [_render_header(record, chain, now), Text(), *_render_chain(record, chain, now, pulse)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _build_chain(events: list[Event]) -> list[ChainEntry]:
|
|
97
|
+
"""Pair the start and end events into node runs, keeping the journal order.
|
|
98
|
+
|
|
99
|
+
A fanned-out node run hangs off its map node run instead of the chain.
|
|
100
|
+
"""
|
|
101
|
+
chain: list[ChainEntry] = []
|
|
102
|
+
runs_by_name: dict[str, _NodeRun] = {}
|
|
103
|
+
last_run_of_node: dict[str, _NodeRun] = {}
|
|
104
|
+
for event in events:
|
|
105
|
+
match event["event"]:
|
|
106
|
+
case "start":
|
|
107
|
+
node_run = _NodeRun(
|
|
108
|
+
node_run_name=event["node"],
|
|
109
|
+
node_name=parse_node_name(event["node"]),
|
|
110
|
+
start_time=datetime.fromisoformat(event["time"]),
|
|
111
|
+
resumed_session=event.get("session") is not None,
|
|
112
|
+
)
|
|
113
|
+
if event.get("map"):
|
|
114
|
+
last_run_of_node[event["map"]].fanned_out_runs.append(node_run)
|
|
115
|
+
else:
|
|
116
|
+
chain.append(node_run)
|
|
117
|
+
last_run_of_node[node_run.node_name] = node_run
|
|
118
|
+
runs_by_name[node_run.node_run_name] = node_run
|
|
119
|
+
case "end":
|
|
120
|
+
node_run = runs_by_name[event["node"]]
|
|
121
|
+
node_run.end_time = datetime.fromisoformat(event["time"])
|
|
122
|
+
node_run.outcome = "failure" if "failure" in event else event["outcome"]
|
|
123
|
+
node_run.cost = event["cost"]
|
|
124
|
+
case "fallback":
|
|
125
|
+
runs_by_name[event["node"]].fell_back = True
|
|
126
|
+
case "limit" | "stop" | "resume":
|
|
127
|
+
chain.append(event)
|
|
128
|
+
return chain
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _render_header(record: _RunRecord, chain: list[ChainEntry], now: datetime | None) -> Text:
|
|
132
|
+
"""Render `run: <workflow> "<input>" · spent <t> · $<c> · <status>`; $<c> only when non-zero."""
|
|
133
|
+
chain_node_runs = [entry for entry in chain if isinstance(entry, _NodeRun)]
|
|
134
|
+
spent_seconds = sum(
|
|
135
|
+
((node_run.end_time or now or node_run.start_time) - node_run.start_time).total_seconds()
|
|
136
|
+
for node_run in chain_node_runs
|
|
137
|
+
)
|
|
138
|
+
spent_cost = round(sum(node_run.cost for node_run in chain_node_runs), 2)
|
|
139
|
+
run_event = record.events[0]
|
|
140
|
+
head = f'run: {run_event["workflow"]} "{run_event["input"]}"'
|
|
141
|
+
head += f" · spent {format_duration(spent_seconds)}"
|
|
142
|
+
if spent_cost:
|
|
143
|
+
head += f" · ${spent_cost:.2f}"
|
|
144
|
+
return Text(head + " · ").append_text(_render_run_status(record, chain, now))
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _render_run_status(record: _RunRecord, chain: list[ChainEntry], now: datetime | None) -> Text:
|
|
148
|
+
last_entry = chain[-1] if chain else None
|
|
149
|
+
if isinstance(last_entry, dict) and last_entry["event"] == "stop":
|
|
150
|
+
node_name, stop_reason = last_entry["node"], last_entry["reason"]
|
|
151
|
+
if stop_reason == "gate":
|
|
152
|
+
waited_seconds = (
|
|
153
|
+
datetime.now(UTC) - datetime.fromisoformat(last_entry["time"])
|
|
154
|
+
).total_seconds()
|
|
155
|
+
question = record.nodes[node_name]["gate"]
|
|
156
|
+
return Text(
|
|
157
|
+
f"parked at {node_name} for {format_duration(waited_seconds)}: {question}",
|
|
158
|
+
"bold yellow",
|
|
159
|
+
)
|
|
160
|
+
if stop_reason == "end":
|
|
161
|
+
return Text(END, "green")
|
|
162
|
+
return Text(
|
|
163
|
+
f"{stop_reason} at {node_name}", "red" if stop_reason == "failure" else "bold yellow"
|
|
164
|
+
)
|
|
165
|
+
if now is None:
|
|
166
|
+
state = read_state(record.directory) or {"node": record.start_node}
|
|
167
|
+
return Text(f"interrupted at {state['node']}", "bold yellow")
|
|
168
|
+
in_progress_text = ", ".join(
|
|
169
|
+
f"{entry.node_run_name} {_format_node_run_duration(entry, now)}"
|
|
170
|
+
for entry in chain
|
|
171
|
+
if isinstance(entry, _NodeRun) and entry.end_time is None
|
|
172
|
+
)
|
|
173
|
+
return Text(f"running {in_progress_text or 'between nodes'}", "bold")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _format_node_run_duration(node_run: _NodeRun, now: datetime | None) -> str:
|
|
177
|
+
"""Format the node run's duration: `<elapsed>…` while it runs, empty when interrupted."""
|
|
178
|
+
if node_run.end_time is None:
|
|
179
|
+
if now is None:
|
|
180
|
+
return ""
|
|
181
|
+
return format_duration((now - node_run.start_time).total_seconds()) + "…"
|
|
182
|
+
return format_duration((node_run.end_time - node_run.start_time).total_seconds())
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _render_chain(
|
|
186
|
+
record: _RunRecord, chain: list[ChainEntry], now: datetime | None, pulse: float | None
|
|
187
|
+
) -> list[Text]:
|
|
188
|
+
name_width = max(
|
|
189
|
+
(len(entry.node_run_name) for entry in chain if isinstance(entry, _NodeRun)), default=0
|
|
190
|
+
)
|
|
191
|
+
rows: list[_Row] = []
|
|
192
|
+
for index, entry in enumerate(chain):
|
|
193
|
+
if isinstance(entry, _NodeRun):
|
|
194
|
+
rows += _render_node_run(record, entry, now, pulse, name_width)
|
|
195
|
+
elif entry["event"] == "limit":
|
|
196
|
+
rows.append(_Row(Text(f"{GLYPH_LIMIT} {entry['node']} → LIMIT", "yellow")))
|
|
197
|
+
elif entry["event"] == "stop":
|
|
198
|
+
following_entry = chain[index + 1] if index + 1 < len(chain) else None
|
|
199
|
+
rows.append(_render_stop(record, entry, following_entry, name_width))
|
|
200
|
+
else:
|
|
201
|
+
rows.append(_Row(_render_resume(entry)))
|
|
202
|
+
return _join_columns(rows)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _render_node_run(
|
|
206
|
+
record: _RunRecord,
|
|
207
|
+
node_run: _NodeRun,
|
|
208
|
+
now: datetime | None,
|
|
209
|
+
pulse: float | None,
|
|
210
|
+
name_width: int,
|
|
211
|
+
) -> list[_Row]:
|
|
212
|
+
"""Render a node run: its row and outcome edge on the left, its fan-out on the right."""
|
|
213
|
+
left = [_render_node_row(record, node_run, now, pulse, name_width)]
|
|
214
|
+
if node_run.end_time and node_run.outcome != "failure":
|
|
215
|
+
left.append(_render_edge(node_run.outcome or "", _pick_outcome_style(record, node_run)))
|
|
216
|
+
right = _render_fan_out(record, node_run, now, pulse)
|
|
217
|
+
while len(left) < len(right):
|
|
218
|
+
left.append(Text("│", GREY) if node_run.end_time else Text())
|
|
219
|
+
rows = [
|
|
220
|
+
_Row(left_part, right_part)
|
|
221
|
+
for left_part, right_part in zip_longest(left, right, fillvalue=Text())
|
|
222
|
+
]
|
|
223
|
+
if right:
|
|
224
|
+
rows[0].connects = True
|
|
225
|
+
return rows
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _render_fan_out(
|
|
229
|
+
record: _RunRecord, node_run: _NodeRun, now: datetime | None, pulse: float | None
|
|
230
|
+
) -> list[Text]:
|
|
231
|
+
fanned_out_runs = node_run.fanned_out_runs
|
|
232
|
+
rows = []
|
|
233
|
+
for index, fanned_out_run in enumerate(fanned_out_runs):
|
|
234
|
+
last_index = len(fanned_out_runs) - 1
|
|
235
|
+
connector = (
|
|
236
|
+
("─" if last_index == 0 else "┬") if index == 0 else "└" if index == last_index else "├"
|
|
237
|
+
)
|
|
238
|
+
row = Text().append(connector + " ", GREY)
|
|
239
|
+
row.append_text(_render_node_row(record, fanned_out_run, now, pulse))
|
|
240
|
+
if fanned_out_run.outcome:
|
|
241
|
+
row.append(" " + fanned_out_run.outcome, _pick_outcome_style(record, fanned_out_run))
|
|
242
|
+
rows.append(row)
|
|
243
|
+
return rows
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _render_node_row(
|
|
247
|
+
record: _RunRecord,
|
|
248
|
+
node_run: _NodeRun,
|
|
249
|
+
now: datetime | None,
|
|
250
|
+
pulse: float | None,
|
|
251
|
+
name_width: int = 0,
|
|
252
|
+
) -> Text:
|
|
253
|
+
row = Text()
|
|
254
|
+
# A fallback ends the resumed spawn, so the fresh spawn draws the regular glyphs.
|
|
255
|
+
resumed_glyph = GLYPH_RESUMED if node_run.resumed_session and not node_run.fell_back else None
|
|
256
|
+
if node_run.end_time is None:
|
|
257
|
+
row.append(
|
|
258
|
+
resumed_glyph or GLYPH_CURRENT,
|
|
259
|
+
"bold" if pulse is None else _pick_pulse_style(pulse),
|
|
260
|
+
)
|
|
261
|
+
row.append(
|
|
262
|
+
f" {node_run.node_run_name.ljust(name_width)} {_format_node_run_duration(node_run, now)}",
|
|
263
|
+
"bold",
|
|
264
|
+
)
|
|
265
|
+
return _append_resumed_suffix(record, row, node_run)
|
|
266
|
+
style = _pick_outcome_style(record, node_run)
|
|
267
|
+
glyph = {"green": GLYPH_PASS, "red": GLYPH_FAIL}.get(style, resumed_glyph or GLYPH_PAST)
|
|
268
|
+
row.append(f"{glyph} {node_run.node_run_name.ljust(name_width)}", style)
|
|
269
|
+
row.append(" " + _format_node_run_duration(node_run, now), GREY)
|
|
270
|
+
if "agent" in record.nodes[node_run.node_name]:
|
|
271
|
+
row.append(f" ${node_run.cost:.2f}", GREY)
|
|
272
|
+
return _append_resumed_suffix(record, row, node_run)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _append_resumed_suffix(record: _RunRecord, row: Text, node_run: _NodeRun) -> Text:
|
|
276
|
+
"""Append the node run the row resumed the session of, then its fallback."""
|
|
277
|
+
row.append(format_resumed_suffix(record, node_run.node_run_name), GREY)
|
|
278
|
+
if node_run.fell_back:
|
|
279
|
+
row.append(" fallback", "yellow")
|
|
280
|
+
return row
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _pick_outcome_style(record: _RunRecord, node_run: _NodeRun) -> str:
|
|
284
|
+
"""Color only coded outcomes: command and map pass and fail, and a failure."""
|
|
285
|
+
if node_run.outcome == "failure":
|
|
286
|
+
return "red"
|
|
287
|
+
if "agent" in record.nodes[node_run.node_name]:
|
|
288
|
+
return ""
|
|
289
|
+
return "green" if node_run.outcome == "pass" else "red"
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _render_edge(label: str, style: str) -> Text:
|
|
293
|
+
return Text().append("│ ", GREY).append(label, style)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _render_stop(
|
|
297
|
+
record: _RunRecord, event: Event, following_entry: ChainEntry | None, name_width: int
|
|
298
|
+
) -> _Row:
|
|
299
|
+
stop_reason = event["reason"]
|
|
300
|
+
if stop_reason == "gate":
|
|
301
|
+
return _Row(_render_gate(event, following_entry, name_width))
|
|
302
|
+
if stop_reason == "end":
|
|
303
|
+
return _Row(Text(END, "green"))
|
|
304
|
+
if stop_reason == "failure":
|
|
305
|
+
message = next(
|
|
306
|
+
end_event["failure"]
|
|
307
|
+
for end_event in reversed(record.events)
|
|
308
|
+
if end_event["event"] == "end" and "failure" in end_event
|
|
309
|
+
)
|
|
310
|
+
return _Row(Text(f"{GLYPH_FAIL} failure: {message}", "red"), overruns=True)
|
|
311
|
+
return _Row(
|
|
312
|
+
Text(f"{GLYPH_WARN} {stop_reason} at {event['node']}", "bold yellow"), overruns=True
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _render_gate(event: Event, following_entry: ChainEntry | None, name_width: int) -> Text:
|
|
317
|
+
"""Render the gate: its wait until the resume, or `parked <wait>` while it waits."""
|
|
318
|
+
stop_time = datetime.fromisoformat(event["time"])
|
|
319
|
+
if isinstance(following_entry, dict) and following_entry["event"] == "resume":
|
|
320
|
+
waited_seconds = (
|
|
321
|
+
datetime.fromisoformat(following_entry["time"]) - stop_time
|
|
322
|
+
).total_seconds()
|
|
323
|
+
row = Text(
|
|
324
|
+
f"{GLYPH_GATE} {event['node'].ljust(name_width)}",
|
|
325
|
+
DECISION_STYLE[following_entry["decision"]],
|
|
326
|
+
)
|
|
327
|
+
return row.append(f" {format_duration(waited_seconds)}", GREY)
|
|
328
|
+
waited_seconds = (datetime.now(UTC) - stop_time).total_seconds()
|
|
329
|
+
return Text(
|
|
330
|
+
f"{GLYPH_GATE} {event['node'].ljust(name_width)} parked {format_duration(waited_seconds)}",
|
|
331
|
+
"bold yellow",
|
|
332
|
+
)
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def _render_resume(event: Event) -> Text:
|
|
336
|
+
if event.get("decision"):
|
|
337
|
+
return _render_edge(event["decision"], DECISION_STYLE[event["decision"]])
|
|
338
|
+
return _render_edge("resumed", GREY)
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _join_columns(rows: list[_Row]) -> list[Text]:
|
|
342
|
+
"""Chain the left column; reach a fan-out on the right with a `─` fill."""
|
|
343
|
+
left_column_width = max((row.left.cell_len for row in rows if not row.overruns), default=0) + 1
|
|
344
|
+
lines = []
|
|
345
|
+
for row in rows:
|
|
346
|
+
line = row.left.copy()
|
|
347
|
+
line.append(" ")
|
|
348
|
+
line.pad_right(left_column_width - line.cell_len, "─" if row.connects else " ")
|
|
349
|
+
if row.connects:
|
|
350
|
+
line.stylize(GREY, row.left.cell_len, left_column_width)
|
|
351
|
+
line.append_text(row.right)
|
|
352
|
+
line.rstrip()
|
|
353
|
+
lines.append(line)
|
|
354
|
+
return lines
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _pick_pulse_style(pulse: float) -> str:
|
|
358
|
+
"""Grey level of the current glyph: a sine fade between #606060 and white."""
|
|
359
|
+
grey_level = int(96 + 159 * (0.5 + 0.5 * math.sin(2 * math.pi * pulse)))
|
|
360
|
+
return f"bold #{grey_level:02x}{grey_level:02x}{grey_level:02x}"
|
workgraph/harness.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""The harness interface: the runtimes that execute an agent node run."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from collections.abc import Iterable, Iterator, Sequence
|
|
5
|
+
from contextlib import AbstractContextManager
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any, Protocol
|
|
8
|
+
|
|
9
|
+
from rich.text import Text
|
|
10
|
+
|
|
11
|
+
# The accepted harness names; find_harness maps each to the module that implements Harness.
|
|
12
|
+
HARNESS_NAMES: tuple[str, ...] = ("claude", "codex")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class NodeFailure(Exception):
|
|
16
|
+
"""A node run ended without an outcome; the run stops."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, message: str, cost: float = 0.0) -> None:
|
|
19
|
+
super().__init__(message)
|
|
20
|
+
# The cost the harness reported before the failure, so the run still counts it.
|
|
21
|
+
self.cost = cost
|
|
22
|
+
# The agent session the failed node run ended with, so a re-entry resumes it.
|
|
23
|
+
self.session: str | None = None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True)
|
|
27
|
+
class AgentInvocation:
|
|
28
|
+
"""What a harness needs to run an agent node."""
|
|
29
|
+
|
|
30
|
+
agent_node_name: str
|
|
31
|
+
agent_name: str
|
|
32
|
+
agent_definition: dict[str, str]
|
|
33
|
+
prompt: str
|
|
34
|
+
model: str
|
|
35
|
+
effort: str
|
|
36
|
+
outcomes: list[str]
|
|
37
|
+
allowed_tools: str | None = None
|
|
38
|
+
sandbox: str = "workspace-write"
|
|
39
|
+
web_search: Any = None
|
|
40
|
+
# The agent session to resume; None spawns a fresh session.
|
|
41
|
+
session: str | None = None
|
|
42
|
+
|
|
43
|
+
@property
|
|
44
|
+
def outcome_schema(self) -> dict[str, Any]:
|
|
45
|
+
"""The JSON schema of the structured output the agent reports."""
|
|
46
|
+
return {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"properties": {
|
|
49
|
+
"outcome": {"enum": self.outcomes},
|
|
50
|
+
"handoff": {
|
|
51
|
+
"type": "string",
|
|
52
|
+
"description": "Optional free text delivered to the next node of the workflow.",
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
"required": ["outcome"],
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class Harness(Protocol):
|
|
60
|
+
"""What workgraph needs of a harness to run an agent node and render its output."""
|
|
61
|
+
|
|
62
|
+
def build_argv(self, invocation: AgentInvocation) -> AbstractContextManager[list[str]]:
|
|
63
|
+
"""Yield the argv that runs the agent; a file the argv names exists for the with block."""
|
|
64
|
+
|
|
65
|
+
def read_result(
|
|
66
|
+
self, invocation: AgentInvocation, stdout_lines: Sequence[str]
|
|
67
|
+
) -> tuple[Any, float]:
|
|
68
|
+
"""Return the structured output the agent reported and the USD cost of the node run.
|
|
69
|
+
|
|
70
|
+
read_result raises NodeFailure when the output holds no result or the agent reported
|
|
71
|
+
an error; the failure carries the cost.
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
def read_session(self, stdout_lines: Sequence[str]) -> str | None:
|
|
75
|
+
"""Return the agent session the output names; None when it names none."""
|
|
76
|
+
|
|
77
|
+
def render_transcript(self, stdout_lines: Sequence[str]) -> list[Text]:
|
|
78
|
+
"""Render agent stdout lines as transcript rows."""
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def iter_jsonl_events(lines: Iterable[str]) -> Iterator[dict[str, Any]]:
|
|
82
|
+
"""Yield the JSON objects among JSONL lines; drop every other line."""
|
|
83
|
+
for line in lines:
|
|
84
|
+
try:
|
|
85
|
+
event = json.loads(line)
|
|
86
|
+
except json.JSONDecodeError:
|
|
87
|
+
continue
|
|
88
|
+
if isinstance(event, dict):
|
|
89
|
+
yield event
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def read_last_value(events: Iterable[dict[str, Any]], key: str) -> str | None:
|
|
93
|
+
"""Return the value of the key in the last event that carries it; None when none does."""
|
|
94
|
+
values = [event[key] for event in events if key in event]
|
|
95
|
+
return str(values[-1]) if values else None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def split_lines(text: str | None) -> list[Text]:
|
|
99
|
+
"""Split on newlines only; a `\\r` or `\\f` stays in its line."""
|
|
100
|
+
lines = (text or "").split("\n")
|
|
101
|
+
if not lines[-1]:
|
|
102
|
+
lines.pop()
|
|
103
|
+
return [Text(line) for line in lines]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def find_harness(harness_name: str) -> Harness:
|
|
107
|
+
"""Return the harness module the name selects."""
|
|
108
|
+
# The harness modules import this module, so the import waits for the call.
|
|
109
|
+
from workgraph import claude, codex
|
|
110
|
+
|
|
111
|
+
harnesses: dict[str, Harness] = {"claude": claude, "codex": codex}
|
|
112
|
+
return harnesses[harness_name]
|