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
workgraph/run.py
ADDED
|
@@ -0,0 +1,942 @@
|
|
|
1
|
+
"""Run a workflow of agent, command, map, and gate nodes."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import shlex
|
|
5
|
+
import shutil
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Iterator, Sequence
|
|
10
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
11
|
+
from contextlib import contextmanager
|
|
12
|
+
from dataclasses import dataclass, replace
|
|
13
|
+
from datetime import UTC, datetime
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any, cast
|
|
16
|
+
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from workgraph.harness import AgentInvocation, NodeFailure, find_harness
|
|
21
|
+
from workgraph.workflow import END, LIMIT, list_definition_directories, resolve_agent_settings
|
|
22
|
+
|
|
23
|
+
RUN_DIR = Path(".workgraph") / "run"
|
|
24
|
+
STATE_FILE = RUN_DIR / "state.json"
|
|
25
|
+
JOURNAL_FILE = RUN_DIR / "journal.jsonl"
|
|
26
|
+
LOCK_FILE = Path(".workgraph") / "run.lock"
|
|
27
|
+
|
|
28
|
+
# Secondary text style.
|
|
29
|
+
GREY = "grey66"
|
|
30
|
+
|
|
31
|
+
_JOURNAL_LOCK = threading.Lock()
|
|
32
|
+
_STATE_LOCK = threading.Lock()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class NodeRunResult:
|
|
37
|
+
"""What a node run produced: its outcome, its handoff, its USD cost, and its agent session.
|
|
38
|
+
|
|
39
|
+
Only an agent node run has a session.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
outcome: str
|
|
43
|
+
handoff: str | None
|
|
44
|
+
cost: float
|
|
45
|
+
session: str | None = None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class RunInProgress(Exception):
|
|
49
|
+
"""Another run holds the target directory; only one run may."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class NothingToResume(Exception):
|
|
53
|
+
"""There is no stopped run to resume."""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class Escalation(Exception):
|
|
57
|
+
"""A node hit its visit limit and has no LIMIT transition; the run stops."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class Park(Exception):
|
|
61
|
+
"""A gate node waits for a human decision; the run stops."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class BudgetStop(Exception):
|
|
65
|
+
"""A spent amount reached a limit of a budget; the run stops."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class DecisionError(Exception):
|
|
69
|
+
"""The resume flags do not fit the stopped run."""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_workflow(
|
|
73
|
+
workflow_name: str, workflow: dict[str, Any], run_input: str, directory: Path
|
|
74
|
+
) -> None:
|
|
75
|
+
"""Run the workflow from its start node until END or a stop.
|
|
76
|
+
|
|
77
|
+
Runs the nodes in directory. Writes the run record under RUN_DIR:
|
|
78
|
+
- STATE_FILE before and after each node run
|
|
79
|
+
- JOURNAL_FILE as events happen
|
|
80
|
+
- one output file per stream per node run
|
|
81
|
+
A run wipes the previous run record. Prints one progress line per node run
|
|
82
|
+
and ends on the stop line. Holds LOCK_FILE in directory for the whole run;
|
|
83
|
+
one run per directory.
|
|
84
|
+
"""
|
|
85
|
+
state: dict[str, Any] = {
|
|
86
|
+
"workflow": workflow_name,
|
|
87
|
+
"input": run_input,
|
|
88
|
+
"node": workflow["start"],
|
|
89
|
+
"visits": {},
|
|
90
|
+
}
|
|
91
|
+
with _lock(directory):
|
|
92
|
+
shutil.rmtree(directory / RUN_DIR, ignore_errors=True)
|
|
93
|
+
(directory / RUN_DIR).mkdir()
|
|
94
|
+
_append_journal_event(directory, "run", workflow=workflow_name, input=run_input)
|
|
95
|
+
_run_nodes(workflow, state, directory, grace_entry=False, stop_node=state["node"])
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def read_state(directory: Path) -> dict[str, Any] | None:
|
|
99
|
+
"""Read the run state from STATE_FILE in directory; None when there is no state file."""
|
|
100
|
+
path = directory / STATE_FILE
|
|
101
|
+
return dict(json.loads(path.read_text())) if path.exists() else None
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def load_state(directory: Path) -> dict[str, Any]:
|
|
105
|
+
"""Read the state of a resumable run.
|
|
106
|
+
|
|
107
|
+
load_state raises NothingToResume when no state file exists or the run reached END.
|
|
108
|
+
"""
|
|
109
|
+
state = read_state(directory)
|
|
110
|
+
if state is None:
|
|
111
|
+
raise NothingToResume(f"no run state at {directory / STATE_FILE}; nothing to resume")
|
|
112
|
+
if state["node"] == END:
|
|
113
|
+
raise NothingToResume("the run reached END; nothing to resume")
|
|
114
|
+
return state
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def format_review_material(handoff: Sequence[str] | None) -> str:
|
|
118
|
+
"""Format the review material a gate shows the human."""
|
|
119
|
+
if handoff is None:
|
|
120
|
+
return "No review material."
|
|
121
|
+
source, text = handoff
|
|
122
|
+
return f"Review material from {source}:\n{text}"
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def format_duration(seconds: float) -> str:
|
|
126
|
+
"""Format whole seconds as 5s, 4m05s, or 2h30m."""
|
|
127
|
+
whole_seconds = int(seconds)
|
|
128
|
+
if whole_seconds < 60:
|
|
129
|
+
return f"{whole_seconds}s"
|
|
130
|
+
if whole_seconds < 3600:
|
|
131
|
+
return f"{whole_seconds // 60}m{whole_seconds % 60:02d}s"
|
|
132
|
+
return f"{whole_seconds // 3600}h{whole_seconds % 3600 // 60:02d}m"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _format_spent_text(state: dict[str, Any]) -> str:
|
|
136
|
+
"""Format the spent amounts: ` · spent <t>`, then ` · $<c>` when the cost is non-zero."""
|
|
137
|
+
text = f" · spent {format_duration(state.get('spent_time', 0))}"
|
|
138
|
+
cost = round(state.get("spent_cost", 0), 2)
|
|
139
|
+
return text + (f" · ${cost:.2f}" if cost else "")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def format_stop_line(state: dict[str, Any], stop_reason: str, question: str | None = None) -> Text:
|
|
143
|
+
"""Format the stop as one line: END, or `<reason> at <node>`, then the spent amounts.
|
|
144
|
+
|
|
145
|
+
question is the question of the gate node a parked run stopped at.
|
|
146
|
+
"""
|
|
147
|
+
node_name = state["node"]
|
|
148
|
+
match stop_reason:
|
|
149
|
+
case "end":
|
|
150
|
+
head, style = END, "green"
|
|
151
|
+
case "gate":
|
|
152
|
+
head, style = f"parked at {node_name}: {question}", "bold yellow"
|
|
153
|
+
case _:
|
|
154
|
+
head, style = (
|
|
155
|
+
f"{stop_reason} at {node_name}",
|
|
156
|
+
"red" if stop_reason == "failure" else "bold yellow",
|
|
157
|
+
)
|
|
158
|
+
return Text(head, style).append(_format_spent_text(state), GREY)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def format_running_line(state: dict[str, Any], journal: list[dict[str, Any]]) -> Text:
|
|
162
|
+
"""Format the running line of a run in progress from the journal's last start event.
|
|
163
|
+
|
|
164
|
+
`running <node run> <elapsed>… · spent <t>`; a fanned-out node run reads `<map>/<node run>`.
|
|
165
|
+
Before the first node run, the line names the node the run enters, timed from the
|
|
166
|
+
run or resume event.
|
|
167
|
+
"""
|
|
168
|
+
last_started_event = next(
|
|
169
|
+
event for event in reversed(journal) if event["event"] in ("start", "run", "resume")
|
|
170
|
+
)
|
|
171
|
+
running_name = last_started_event.get("node", state["node"])
|
|
172
|
+
if last_started_event.get("map"):
|
|
173
|
+
running_name = f"{last_started_event['map']}/{running_name}"
|
|
174
|
+
elapsed = datetime.now(UTC) - datetime.fromisoformat(last_started_event["time"])
|
|
175
|
+
text = Text(f"running {running_name} {format_duration(elapsed.total_seconds())}…", "bold")
|
|
176
|
+
return text.append(_format_spent_text(state), GREY)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def read_journal(directory: Path) -> list[dict[str, Any]]:
|
|
180
|
+
"""Read the journal events; a trailing partial line is dropped, a missing journal is empty."""
|
|
181
|
+
path = directory / JOURNAL_FILE
|
|
182
|
+
lines = path.read_text().split("\n")[:-1] if path.exists() else []
|
|
183
|
+
return [json.loads(line) for line in lines]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def echo(text: Text) -> None:
|
|
187
|
+
"""Print one styled line: colors on a terminal, plain when piped."""
|
|
188
|
+
Console(highlight=False).print(text, soft_wrap=True)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def compute_time_limits(workflow: dict[str, Any], state: dict[str, Any]) -> dict[str, float]:
|
|
192
|
+
"""Return the effective soft and hard limits: each declared limit plus the grants."""
|
|
193
|
+
added_time = state.get("added_time", 0)
|
|
194
|
+
return {
|
|
195
|
+
key.removeprefix("time_"): limit + added_time
|
|
196
|
+
for key, limit in workflow.get("budget", {}).items()
|
|
197
|
+
if key.startswith("time_")
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def compute_cost_limit(workflow: dict[str, Any], state: dict[str, Any]) -> float | None:
|
|
202
|
+
"""Return the effective cost limit: the declared limit plus the grants; None when undeclared."""
|
|
203
|
+
limit = workflow.get("budget", {}).get("cost")
|
|
204
|
+
return None if limit is None else limit + state.get("added_cost", 0)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def resume_run(
|
|
208
|
+
workflow: dict[str, Any],
|
|
209
|
+
state: dict[str, Any],
|
|
210
|
+
directory: Path,
|
|
211
|
+
decision: str | None = None,
|
|
212
|
+
feedback: str | None = None,
|
|
213
|
+
add_time: float | None = None,
|
|
214
|
+
add_cost: float | None = None,
|
|
215
|
+
) -> None:
|
|
216
|
+
"""Resume the stopped run from its saved state.
|
|
217
|
+
|
|
218
|
+
After a failure, an escalation, or a budget stop, the run enters the stopped
|
|
219
|
+
node with the undelivered handoff. After a park, the run follows the gate's
|
|
220
|
+
transition for the decision instead: accept forwards the pending handoff,
|
|
221
|
+
reject delivers the feedback as JSON. Every resume causes a grace entry: the
|
|
222
|
+
entry does not count toward the visit limit.
|
|
223
|
+
add_time grants seconds to every declared time limit and add_cost grants
|
|
224
|
+
USD to the declared cost limit; grants accumulate. resume_run raises
|
|
225
|
+
DecisionError when the flags do not fit the run, or when a spent amount is
|
|
226
|
+
at or past an effective limit after the grants. The resume appends to the
|
|
227
|
+
run record.
|
|
228
|
+
"""
|
|
229
|
+
current_node = state["node"]
|
|
230
|
+
stop_reason = state.get("stopped")
|
|
231
|
+
_check_decision(current_node, stop_reason == "gate", decision, feedback)
|
|
232
|
+
resume_event: dict[str, Any] = {}
|
|
233
|
+
if add_time is not None:
|
|
234
|
+
if not compute_time_limits(workflow, state):
|
|
235
|
+
raise DecisionError("the workflow declares no time limit; drop --add-time")
|
|
236
|
+
state["added_time"] = state.get("added_time", 0) + add_time
|
|
237
|
+
resume_event["add_time"] = add_time
|
|
238
|
+
for limit_kind, limit in compute_time_limits(workflow, state).items():
|
|
239
|
+
if state.get("spent_time", 0) >= limit:
|
|
240
|
+
raise DecisionError(
|
|
241
|
+
f"the run is at or past its {limit_kind} time limit of {limit:g} s;"
|
|
242
|
+
" pass --add-time to resume"
|
|
243
|
+
)
|
|
244
|
+
if add_cost is not None:
|
|
245
|
+
if compute_cost_limit(workflow, state) is None:
|
|
246
|
+
raise DecisionError("the workflow declares no cost limit; drop --add-cost")
|
|
247
|
+
state["added_cost"] = state.get("added_cost", 0) + add_cost
|
|
248
|
+
resume_event["add_cost"] = add_cost
|
|
249
|
+
cost_limit = compute_cost_limit(workflow, state)
|
|
250
|
+
if cost_limit is not None and state.get("spent_cost", 0) >= cost_limit:
|
|
251
|
+
raise DecisionError(
|
|
252
|
+
f"the run is at or past its cost limit of {cost_limit:g} USD; pass --add-cost to resume"
|
|
253
|
+
)
|
|
254
|
+
if decision is not None:
|
|
255
|
+
resume_event.update(decision=decision, feedback=feedback)
|
|
256
|
+
if decision == "reject":
|
|
257
|
+
saved_handoff = state.get("handoff")
|
|
258
|
+
received_text = saved_handoff[1] if saved_handoff else None
|
|
259
|
+
state["handoff"] = [
|
|
260
|
+
current_node,
|
|
261
|
+
json.dumps({"received": received_text, "feedback": feedback}),
|
|
262
|
+
]
|
|
263
|
+
state["node"] = workflow["nodes"][current_node]["transitions"][decision]
|
|
264
|
+
# The resume drops the stop it resumes from.
|
|
265
|
+
state.update(stopped=None, reason=None)
|
|
266
|
+
with _lock(directory):
|
|
267
|
+
_record_event(directory, current_node, decision, "resume", **resume_event)
|
|
268
|
+
_run_nodes(workflow, state, directory, grace_entry=True, stop_node=current_node)
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _check_decision(
|
|
272
|
+
node_name: str, parked: bool, decision: str | None, feedback: str | None
|
|
273
|
+
) -> None:
|
|
274
|
+
if parked and decision is None:
|
|
275
|
+
raise DecisionError(
|
|
276
|
+
f"the run is parked at gate '{node_name}'; pass --decision accept or reject"
|
|
277
|
+
)
|
|
278
|
+
if not parked and decision is not None:
|
|
279
|
+
raise DecisionError(
|
|
280
|
+
f"the run stopped at node '{node_name}', not at a gate; drop --decision"
|
|
281
|
+
)
|
|
282
|
+
if decision == "reject" and not feedback:
|
|
283
|
+
raise DecisionError("--decision reject requires --feedback")
|
|
284
|
+
if decision == "accept" and feedback is not None:
|
|
285
|
+
raise DecisionError("--decision accept does not take --feedback")
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def is_in_progress(directory: Path) -> bool:
|
|
289
|
+
"""Return whether a run holds the lock in the directory."""
|
|
290
|
+
return (directory / LOCK_FILE).exists()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@contextmanager
|
|
294
|
+
def _lock(directory: Path) -> Iterator[None]:
|
|
295
|
+
lock_file = directory / LOCK_FILE
|
|
296
|
+
lock_file.parent.mkdir(exist_ok=True)
|
|
297
|
+
try:
|
|
298
|
+
# ponytail: a killed process leaves a stale lock; store a pid if this bites.
|
|
299
|
+
lock_file.touch(exist_ok=False)
|
|
300
|
+
except FileExistsError:
|
|
301
|
+
raise RunInProgress(
|
|
302
|
+
f"a run is already in progress in {directory}; delete {lock_file} if it is stale"
|
|
303
|
+
) from None
|
|
304
|
+
try:
|
|
305
|
+
yield
|
|
306
|
+
finally:
|
|
307
|
+
lock_file.unlink()
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def _run_nodes(
|
|
311
|
+
workflow: dict[str, Any],
|
|
312
|
+
state: dict[str, Any],
|
|
313
|
+
directory: Path,
|
|
314
|
+
grace_entry: bool,
|
|
315
|
+
stop_node: str,
|
|
316
|
+
) -> None:
|
|
317
|
+
nodes = workflow["nodes"]
|
|
318
|
+
defaults = workflow.get("defaults", {})
|
|
319
|
+
time_limits = compute_time_limits(workflow, state)
|
|
320
|
+
cost_limit = compute_cost_limit(workflow, state)
|
|
321
|
+
run_input = state["input"]
|
|
322
|
+
visits = state["visits"]
|
|
323
|
+
current_node = state["node"]
|
|
324
|
+
saved_handoff = state.get("handoff")
|
|
325
|
+
handoff = (saved_handoff[0], saved_handoff[1]) if saved_handoff else None
|
|
326
|
+
state.setdefault("spent_time", 0.0)
|
|
327
|
+
state.setdefault("spent_cost", 0.0)
|
|
328
|
+
state.setdefault("node_runs", {})
|
|
329
|
+
diverted_nodes: set[str] = set()
|
|
330
|
+
while current_node != END:
|
|
331
|
+
node_definition = nodes[current_node]
|
|
332
|
+
if "gate" in node_definition:
|
|
333
|
+
_stop_run(
|
|
334
|
+
state,
|
|
335
|
+
directory,
|
|
336
|
+
current_node,
|
|
337
|
+
handoff,
|
|
338
|
+
"gate",
|
|
339
|
+
question=node_definition["gate"],
|
|
340
|
+
progress_word="parked",
|
|
341
|
+
)
|
|
342
|
+
print(format_review_material(handoff), flush=True)
|
|
343
|
+
raise Park
|
|
344
|
+
node_limits = node_definition.get("limits", {})
|
|
345
|
+
visit_limit = node_limits.get("visits")
|
|
346
|
+
if (
|
|
347
|
+
not grace_entry
|
|
348
|
+
and visit_limit is not None
|
|
349
|
+
and visits.get(current_node, 0) >= visit_limit
|
|
350
|
+
):
|
|
351
|
+
if LIMIT not in node_definition["transitions"]:
|
|
352
|
+
error: Exception = Escalation(
|
|
353
|
+
f"node '{current_node}' reached its visit limit of {visit_limit} and has no LIMIT transition"
|
|
354
|
+
)
|
|
355
|
+
_stop_run(state, directory, current_node, handoff, "escalation", error)
|
|
356
|
+
raise error
|
|
357
|
+
if current_node in diverted_nodes:
|
|
358
|
+
error = Escalation(
|
|
359
|
+
f"node '{current_node}' reached its visit limit of {visit_limit}"
|
|
360
|
+
" and its LIMIT transitions loop without running a node"
|
|
361
|
+
)
|
|
362
|
+
_stop_run(state, directory, current_node, handoff, "escalation", error)
|
|
363
|
+
raise error
|
|
364
|
+
diverted_nodes.add(current_node)
|
|
365
|
+
_append_journal_event(
|
|
366
|
+
directory, "limit", node=current_node, target=node_definition["transitions"][LIMIT]
|
|
367
|
+
)
|
|
368
|
+
stop_node = current_node
|
|
369
|
+
current_node = node_definition["transitions"][LIMIT]
|
|
370
|
+
continue
|
|
371
|
+
diverted_nodes.clear()
|
|
372
|
+
spent_time = state["spent_time"]
|
|
373
|
+
for limit_kind in ("hard", "soft"):
|
|
374
|
+
if limit_kind in time_limits and spent_time >= time_limits[limit_kind]:
|
|
375
|
+
error = BudgetStop(
|
|
376
|
+
f"node '{current_node}': {limit_kind} time limit of {time_limits[limit_kind]:g} s reached"
|
|
377
|
+
)
|
|
378
|
+
_stop_run(
|
|
379
|
+
state,
|
|
380
|
+
directory,
|
|
381
|
+
current_node,
|
|
382
|
+
handoff,
|
|
383
|
+
"budget",
|
|
384
|
+
error,
|
|
385
|
+
progress_word="budget",
|
|
386
|
+
)
|
|
387
|
+
raise error
|
|
388
|
+
if cost_limit is not None and state["spent_cost"] >= cost_limit:
|
|
389
|
+
error = BudgetStop(f"node '{current_node}': cost limit of {cost_limit:g} USD reached")
|
|
390
|
+
_stop_run(
|
|
391
|
+
state, directory, current_node, handoff, "budget", error, progress_word="budget"
|
|
392
|
+
)
|
|
393
|
+
raise error
|
|
394
|
+
if grace_entry:
|
|
395
|
+
grace_entry = False
|
|
396
|
+
else:
|
|
397
|
+
visits[current_node] = visits.get(current_node, 0) + 1
|
|
398
|
+
hard_time_limit = time_limits.get("hard")
|
|
399
|
+
# _take_session runs before _name_next_node_run saves the state, so a node run cut
|
|
400
|
+
# before its end leaves no session and its re-entry starts fresh.
|
|
401
|
+
resumed_session = _take_session(state, current_node) if "agent" in node_definition else None
|
|
402
|
+
node_run_name = _name_next_node_run(state, directory, current_node)
|
|
403
|
+
_start_node_run(directory, node_run_name, node_definition, handoff, session=resumed_session)
|
|
404
|
+
started_monotonic = time.monotonic()
|
|
405
|
+
try:
|
|
406
|
+
if "agent" in node_definition:
|
|
407
|
+
result = _run_agent(
|
|
408
|
+
node_run_name,
|
|
409
|
+
node_definition,
|
|
410
|
+
defaults,
|
|
411
|
+
run_input,
|
|
412
|
+
handoff,
|
|
413
|
+
directory,
|
|
414
|
+
hard_time_limit,
|
|
415
|
+
spent_time,
|
|
416
|
+
resumed_session,
|
|
417
|
+
)
|
|
418
|
+
elif "map" in node_definition:
|
|
419
|
+
result = _run_map(
|
|
420
|
+
node_run_name,
|
|
421
|
+
node_definition,
|
|
422
|
+
state,
|
|
423
|
+
workflow,
|
|
424
|
+
handoff,
|
|
425
|
+
directory,
|
|
426
|
+
hard_time_limit,
|
|
427
|
+
spent_time,
|
|
428
|
+
)
|
|
429
|
+
else:
|
|
430
|
+
result = _run_command(
|
|
431
|
+
node_run_name, node_definition, directory, hard_time_limit, spent_time
|
|
432
|
+
)
|
|
433
|
+
except KeyboardInterrupt:
|
|
434
|
+
# The run record already names the node: no end, no stop.
|
|
435
|
+
echo(format_stop_line(state, "interrupted"))
|
|
436
|
+
raise
|
|
437
|
+
except NodeFailure as error:
|
|
438
|
+
state["spent_time"] += time.monotonic() - started_monotonic
|
|
439
|
+
state["spent_cost"] += error.cost
|
|
440
|
+
_end_node_run(
|
|
441
|
+
directory,
|
|
442
|
+
node_run_name,
|
|
443
|
+
"failure",
|
|
444
|
+
{"failure": str(error)},
|
|
445
|
+
None,
|
|
446
|
+
error.cost,
|
|
447
|
+
error.session,
|
|
448
|
+
state,
|
|
449
|
+
)
|
|
450
|
+
_stop_run(state, directory, current_node, handoff, "failure", error)
|
|
451
|
+
raise error from None
|
|
452
|
+
state["spent_time"] += time.monotonic() - started_monotonic
|
|
453
|
+
state["spent_cost"] += result.cost
|
|
454
|
+
if "agent" in node_definition:
|
|
455
|
+
# workgraph discards the handoff after delivering it to an agent.
|
|
456
|
+
handoff = None
|
|
457
|
+
if result.outcome == node_limits.get("reset"):
|
|
458
|
+
visits.pop(current_node, None)
|
|
459
|
+
target = node_definition["transitions"][result.outcome]
|
|
460
|
+
_end_node_run(
|
|
461
|
+
directory,
|
|
462
|
+
node_run_name,
|
|
463
|
+
result.outcome,
|
|
464
|
+
{"outcome": result.outcome},
|
|
465
|
+
result.handoff,
|
|
466
|
+
result.cost,
|
|
467
|
+
result.session,
|
|
468
|
+
state,
|
|
469
|
+
target,
|
|
470
|
+
)
|
|
471
|
+
# A command or map node that reports no handoff forwards the one it received.
|
|
472
|
+
if target == END:
|
|
473
|
+
handoff = None
|
|
474
|
+
elif result.handoff is not None:
|
|
475
|
+
handoff = (current_node, result.handoff)
|
|
476
|
+
# The state names the node the run enters, so an interrupted run resumes there.
|
|
477
|
+
_write_state(state, directory, target, handoff)
|
|
478
|
+
stop_node = current_node
|
|
479
|
+
current_node = target
|
|
480
|
+
_stop_run(state, directory, stop_node, None, "end")
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _stop_run(
|
|
484
|
+
state: dict[str, Any],
|
|
485
|
+
directory: Path,
|
|
486
|
+
node_name: str,
|
|
487
|
+
handoff: tuple[str, str] | None,
|
|
488
|
+
stop_reason: str,
|
|
489
|
+
error: Exception | None = None,
|
|
490
|
+
question: str | None = None,
|
|
491
|
+
progress_word: str | None = None,
|
|
492
|
+
) -> None:
|
|
493
|
+
"""Write the state, record the stop with its progress line, then print the stop line.
|
|
494
|
+
|
|
495
|
+
node_name is the node the run stops at; a run that reaches END names the last node run's node.
|
|
496
|
+
"""
|
|
497
|
+
if stop_reason == "end":
|
|
498
|
+
_write_state(state, directory, END, None)
|
|
499
|
+
else:
|
|
500
|
+
error_message = None if error is None else str(error)
|
|
501
|
+
_write_state(
|
|
502
|
+
state,
|
|
503
|
+
directory,
|
|
504
|
+
node_name,
|
|
505
|
+
handoff,
|
|
506
|
+
stop_reason=stop_reason,
|
|
507
|
+
error_message=error_message,
|
|
508
|
+
)
|
|
509
|
+
_record_event(directory, node_name, progress_word, "stop", reason=stop_reason, node=node_name)
|
|
510
|
+
echo(format_stop_line(state, stop_reason, question))
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _record_event(
|
|
514
|
+
directory: Path,
|
|
515
|
+
progress_node: str,
|
|
516
|
+
progress_word: str | None,
|
|
517
|
+
event_kind: str,
|
|
518
|
+
**fields: Any,
|
|
519
|
+
) -> None:
|
|
520
|
+
"""Print the event's progress line when it has one, then append the event to the journal."""
|
|
521
|
+
if progress_word is not None:
|
|
522
|
+
print(f"{progress_node}: {progress_word}", flush=True)
|
|
523
|
+
_append_journal_event(directory, event_kind, **fields)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _append_journal_event(directory: Path, event_kind: str, **fields: Any) -> None:
|
|
527
|
+
"""Append one event to the journal: one write per line under the in-process lock."""
|
|
528
|
+
now = datetime.now(UTC).isoformat(timespec="seconds")
|
|
529
|
+
line = json.dumps({"event": event_kind, "time": now, **fields}) + "\n"
|
|
530
|
+
with _JOURNAL_LOCK, (directory / JOURNAL_FILE).open("a") as journal_file:
|
|
531
|
+
journal_file.write(line)
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _name_next_node_run(state: dict[str, Any], directory: Path, node_name: str) -> str:
|
|
535
|
+
"""Count one more node run of the node and name it: <node>#<n>, n from 1 and never reset.
|
|
536
|
+
|
|
537
|
+
The state is saved before the name is used, so a resume after an interruption
|
|
538
|
+
names a new node run.
|
|
539
|
+
"""
|
|
540
|
+
node_run_counts = state["node_runs"]
|
|
541
|
+
node_run_counts[node_name] = node_run_counts.get(node_name, 0) + 1
|
|
542
|
+
_save_state(state, directory)
|
|
543
|
+
return f"{node_name}#{node_run_counts[node_name]}"
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def parse_node_name(node_run_name: str) -> str:
|
|
547
|
+
"""Return the node name of a node run name."""
|
|
548
|
+
return node_run_name.rpartition("#")[0]
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def build_output_path(directory: Path, node_run_name: str, stream: str) -> Path:
|
|
552
|
+
"""Return the path of a node run output file: `<run dir>/<node run>.<stream>`."""
|
|
553
|
+
return directory / RUN_DIR / f"{node_run_name}.{stream}"
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _take_session(state: dict[str, Any], node_name: str) -> str | None:
|
|
557
|
+
"""Pop and return the agent session the node's latest node run ended with."""
|
|
558
|
+
return cast(str | None, state.get("sessions", {}).pop(node_name, None))
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _start_node_run(
|
|
562
|
+
directory: Path,
|
|
563
|
+
node_run_name: str,
|
|
564
|
+
node_definition: dict[str, Any],
|
|
565
|
+
handoff: tuple[str, str] | None,
|
|
566
|
+
map_name: str | None = None,
|
|
567
|
+
session: str | None = None,
|
|
568
|
+
) -> None:
|
|
569
|
+
"""Create the output files of a command or agent node run, then journal its start.
|
|
570
|
+
|
|
571
|
+
A fanned-out node run names its map node; the start event carries map only then.
|
|
572
|
+
It carries session only when the node run resumes one.
|
|
573
|
+
"""
|
|
574
|
+
if "map" not in node_definition:
|
|
575
|
+
for stream in ("stdout", "stderr"):
|
|
576
|
+
build_output_path(directory, node_run_name, stream).touch()
|
|
577
|
+
_append_journal_event(
|
|
578
|
+
directory,
|
|
579
|
+
"start",
|
|
580
|
+
node=node_run_name,
|
|
581
|
+
handoff={"source": handoff[0], "text": handoff[1]} if handoff else None,
|
|
582
|
+
**({} if map_name is None else {"map": map_name}),
|
|
583
|
+
**({} if session is None else {"session": session}),
|
|
584
|
+
)
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def _end_node_run(
|
|
588
|
+
directory: Path,
|
|
589
|
+
node_run_name: str,
|
|
590
|
+
progress_word: str,
|
|
591
|
+
end_fields: dict[str, Any],
|
|
592
|
+
handoff: str | None,
|
|
593
|
+
cost: float,
|
|
594
|
+
session: str | None,
|
|
595
|
+
state: dict[str, Any],
|
|
596
|
+
target: str | None = None,
|
|
597
|
+
map_name: str | None = None,
|
|
598
|
+
) -> None:
|
|
599
|
+
"""Store the session the node run ended with, then record its end with its progress line.
|
|
600
|
+
|
|
601
|
+
end_fields holds the one key the event ends with: outcome or failure. A fanned-out end
|
|
602
|
+
carries no spent amounts, and the event carries session only when the node run has one.
|
|
603
|
+
"""
|
|
604
|
+
node_name = parse_node_name(node_run_name)
|
|
605
|
+
if session is not None:
|
|
606
|
+
# A fan-out ends its node runs from several threads.
|
|
607
|
+
with _STATE_LOCK:
|
|
608
|
+
state.setdefault("sessions", {})[node_name] = session
|
|
609
|
+
_save_state(state, directory)
|
|
610
|
+
spent_amounts = (
|
|
611
|
+
{} if map_name is not None else {key: state[key] for key in ("spent_time", "spent_cost")}
|
|
612
|
+
)
|
|
613
|
+
_record_event(
|
|
614
|
+
directory,
|
|
615
|
+
node_name if map_name is None else f"{map_name}/{node_name}",
|
|
616
|
+
progress_word,
|
|
617
|
+
"end",
|
|
618
|
+
node=node_run_name,
|
|
619
|
+
**end_fields,
|
|
620
|
+
handoff=handoff,
|
|
621
|
+
target=target,
|
|
622
|
+
map=map_name,
|
|
623
|
+
cost=cost,
|
|
624
|
+
**({} if session is None else {"session": session}),
|
|
625
|
+
**spent_amounts,
|
|
626
|
+
)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
def _spawn(
|
|
630
|
+
node_run_name: str,
|
|
631
|
+
command: str | list[str],
|
|
632
|
+
directory: Path,
|
|
633
|
+
hard_time_limit: float | None,
|
|
634
|
+
spent_time: float,
|
|
635
|
+
) -> subprocess.CompletedProcess[bytes]:
|
|
636
|
+
"""Run the command in directory; write its stdout and stderr to the node run's files.
|
|
637
|
+
|
|
638
|
+
The command reads no stdin. _spawn kills it when the spent time reaches the hard limit.
|
|
639
|
+
"""
|
|
640
|
+
timeout = None if hard_time_limit is None else hard_time_limit - spent_time
|
|
641
|
+
try:
|
|
642
|
+
argv = shlex.split(command) if isinstance(command, str) else command
|
|
643
|
+
with (
|
|
644
|
+
build_output_path(directory, node_run_name, "stdout").open("w") as stdout,
|
|
645
|
+
build_output_path(directory, node_run_name, "stderr").open("w") as stderr,
|
|
646
|
+
):
|
|
647
|
+
return subprocess.run(
|
|
648
|
+
argv,
|
|
649
|
+
check=False,
|
|
650
|
+
cwd=directory,
|
|
651
|
+
timeout=timeout,
|
|
652
|
+
stdin=subprocess.DEVNULL,
|
|
653
|
+
stdout=stdout,
|
|
654
|
+
stderr=stderr,
|
|
655
|
+
)
|
|
656
|
+
except (OSError, ValueError, IndexError) as error:
|
|
657
|
+
raise NodeFailure(
|
|
658
|
+
f"node '{parse_node_name(node_run_name)}': spawn failure: {error}"
|
|
659
|
+
) from error
|
|
660
|
+
except subprocess.TimeoutExpired:
|
|
661
|
+
raise NodeFailure(
|
|
662
|
+
f"node '{parse_node_name(node_run_name)}': hard time limit of {hard_time_limit:g} s reached"
|
|
663
|
+
) from None
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
def _run_command(
|
|
667
|
+
node_run_name: str,
|
|
668
|
+
node_definition: dict[str, Any],
|
|
669
|
+
directory: Path,
|
|
670
|
+
hard_time_limit: float | None,
|
|
671
|
+
spent_time: float,
|
|
672
|
+
) -> NodeRunResult:
|
|
673
|
+
"""Run the command; a command node reports no handoff and no cost."""
|
|
674
|
+
completed_process = _spawn(
|
|
675
|
+
node_run_name, node_definition["command"], directory, hard_time_limit, spent_time
|
|
676
|
+
)
|
|
677
|
+
return NodeRunResult("pass" if completed_process.returncode == 0 else "fail", None, 0.0)
|
|
678
|
+
|
|
679
|
+
|
|
680
|
+
def _run_map(
|
|
681
|
+
node_run_name: str,
|
|
682
|
+
node_definition: dict[str, Any],
|
|
683
|
+
state: dict[str, Any],
|
|
684
|
+
workflow: dict[str, Any],
|
|
685
|
+
handoff: tuple[str, str] | None,
|
|
686
|
+
directory: Path,
|
|
687
|
+
hard_time_limit: float | None,
|
|
688
|
+
spent_time: float,
|
|
689
|
+
) -> NodeRunResult:
|
|
690
|
+
map_name = parse_node_name(node_run_name)
|
|
691
|
+
nodes = workflow["nodes"]
|
|
692
|
+
defaults = workflow.get("defaults", {})
|
|
693
|
+
|
|
694
|
+
def run_fanned_out(
|
|
695
|
+
fanned_out_node: str, fanned_out_run_name: str, resumed_session: str | None
|
|
696
|
+
) -> NodeRunResult:
|
|
697
|
+
fanned_out_definition = nodes[fanned_out_node]
|
|
698
|
+
_start_node_run(
|
|
699
|
+
directory,
|
|
700
|
+
fanned_out_run_name,
|
|
701
|
+
fanned_out_definition,
|
|
702
|
+
handoff,
|
|
703
|
+
map_name=map_name,
|
|
704
|
+
session=resumed_session,
|
|
705
|
+
)
|
|
706
|
+
try:
|
|
707
|
+
if "agent" in fanned_out_definition:
|
|
708
|
+
result = _run_agent(
|
|
709
|
+
fanned_out_run_name,
|
|
710
|
+
fanned_out_definition,
|
|
711
|
+
defaults,
|
|
712
|
+
state["input"],
|
|
713
|
+
handoff,
|
|
714
|
+
directory,
|
|
715
|
+
hard_time_limit,
|
|
716
|
+
spent_time,
|
|
717
|
+
resumed_session,
|
|
718
|
+
)
|
|
719
|
+
else:
|
|
720
|
+
result = _run_command(
|
|
721
|
+
fanned_out_run_name,
|
|
722
|
+
fanned_out_definition,
|
|
723
|
+
directory,
|
|
724
|
+
hard_time_limit,
|
|
725
|
+
spent_time,
|
|
726
|
+
)
|
|
727
|
+
end_fields: dict[str, Any] = {"outcome": result.outcome}
|
|
728
|
+
except NodeFailure as error:
|
|
729
|
+
# A fanned-out node's failure counts as not passing; the run continues.
|
|
730
|
+
result = NodeRunResult("fail", None, error.cost, error.session)
|
|
731
|
+
end_fields = {"failure": str(error)}
|
|
732
|
+
_end_node_run(
|
|
733
|
+
directory,
|
|
734
|
+
fanned_out_run_name,
|
|
735
|
+
result.outcome,
|
|
736
|
+
end_fields,
|
|
737
|
+
result.handoff,
|
|
738
|
+
result.cost,
|
|
739
|
+
result.session,
|
|
740
|
+
state,
|
|
741
|
+
map_name=map_name,
|
|
742
|
+
)
|
|
743
|
+
return result
|
|
744
|
+
|
|
745
|
+
fanned_out_nodes = node_definition["map"]
|
|
746
|
+
fanned_out_sessions = [
|
|
747
|
+
_take_session(state, fanned_out_node) for fanned_out_node in fanned_out_nodes
|
|
748
|
+
]
|
|
749
|
+
fanned_out_runs = [
|
|
750
|
+
_name_next_node_run(state, directory, fanned_out_node)
|
|
751
|
+
for fanned_out_node in fanned_out_nodes
|
|
752
|
+
]
|
|
753
|
+
with ThreadPoolExecutor(max_workers=len(fanned_out_nodes)) as pool:
|
|
754
|
+
fanned_out_results = list(
|
|
755
|
+
pool.map(run_fanned_out, fanned_out_nodes, fanned_out_runs, fanned_out_sessions)
|
|
756
|
+
)
|
|
757
|
+
resolve = all if node_definition["resolve"] == "all" else any
|
|
758
|
+
handoff_blocks = [
|
|
759
|
+
f"{fanned_out_node}:\n{result.handoff}"
|
|
760
|
+
for fanned_out_node, result in zip(fanned_out_nodes, fanned_out_results, strict=True)
|
|
761
|
+
if result.handoff is not None
|
|
762
|
+
]
|
|
763
|
+
return NodeRunResult(
|
|
764
|
+
"pass" if resolve(result.outcome == "pass" for result in fanned_out_results) else "fail",
|
|
765
|
+
"\n\n".join(handoff_blocks) if handoff_blocks else None,
|
|
766
|
+
sum(result.cost for result in fanned_out_results),
|
|
767
|
+
)
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _build_agent_message(
|
|
771
|
+
agent_node_name: str,
|
|
772
|
+
run_input: str,
|
|
773
|
+
handoff: tuple[str, str] | None,
|
|
774
|
+
resumed_session: str | None,
|
|
775
|
+
) -> str:
|
|
776
|
+
"""Build the user message of an agent node run.
|
|
777
|
+
|
|
778
|
+
A fresh session receives the run input and the handoff; a resumed session receives the
|
|
779
|
+
handoff alone, or a fixed line when there is none.
|
|
780
|
+
"""
|
|
781
|
+
handoff_block = None if handoff is None else f"Handoff from {handoff[0]}:\n{handoff[1]}"
|
|
782
|
+
if resumed_session is not None:
|
|
783
|
+
return handoff_block or f"The run re-entered {agent_node_name} with no handoff."
|
|
784
|
+
return run_input if handoff_block is None else f"{run_input}\n\n{handoff_block}"
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _read_stdout_lines(directory: Path, node_run_name: str) -> list[str]:
|
|
788
|
+
"""Read the lines a node run wrote to its stdout file."""
|
|
789
|
+
return build_output_path(directory, node_run_name, "stdout").read_text().splitlines()
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _record_fallback(directory: Path, node_run_name: str, exit_message: str) -> None:
|
|
793
|
+
"""Move the resumed spawn's output aside, open fresh files, then journal the fallback.
|
|
794
|
+
|
|
795
|
+
The event comes last, so a follower that reads it finds the fresh output files.
|
|
796
|
+
"""
|
|
797
|
+
for stream in ("stdout", "stderr"):
|
|
798
|
+
plain_path = build_output_path(directory, node_run_name, stream)
|
|
799
|
+
plain_path.rename(build_output_path(directory, node_run_name, f"resume.{stream}"))
|
|
800
|
+
plain_path.touch()
|
|
801
|
+
_append_journal_event(directory, "fallback", node=node_run_name, error=exit_message)
|
|
802
|
+
|
|
803
|
+
|
|
804
|
+
def _run_agent(
|
|
805
|
+
node_run_name: str,
|
|
806
|
+
node_definition: dict[str, Any],
|
|
807
|
+
defaults: dict[str, Any],
|
|
808
|
+
run_input: str,
|
|
809
|
+
handoff: tuple[str, str] | None,
|
|
810
|
+
directory: Path,
|
|
811
|
+
hard_time_limit: float | None,
|
|
812
|
+
spent_time: float,
|
|
813
|
+
resumed_session: str | None,
|
|
814
|
+
) -> NodeRunResult:
|
|
815
|
+
"""Run the agent and return what the node run produced.
|
|
816
|
+
|
|
817
|
+
The harness reads the result from the JSONL events the agent writes to stdout. A resumed
|
|
818
|
+
spawn that exits non-zero falls back to one fresh spawn under the same node run name; the
|
|
819
|
+
resumed spawn's cost and time count toward the run.
|
|
820
|
+
"""
|
|
821
|
+
agent_node_name = parse_node_name(node_run_name)
|
|
822
|
+
# The definition resolves from the invocation directory (the process cwd);
|
|
823
|
+
# only the spawned agent executes in the target directory.
|
|
824
|
+
agent_definition = _load_agent_definition(agent_node_name, node_definition["agent"])
|
|
825
|
+
settings = resolve_agent_settings(node_definition, defaults)
|
|
826
|
+
harness = find_harness(settings["harness"])
|
|
827
|
+
invocation = AgentInvocation(
|
|
828
|
+
agent_node_name=agent_node_name,
|
|
829
|
+
agent_name=node_definition["agent"],
|
|
830
|
+
agent_definition=agent_definition,
|
|
831
|
+
prompt=_build_agent_message(agent_node_name, run_input, handoff, resumed_session),
|
|
832
|
+
model=settings["model"],
|
|
833
|
+
effort=settings["effort"],
|
|
834
|
+
outcomes=node_definition["outcomes"],
|
|
835
|
+
allowed_tools=settings.get("allowed_tools"),
|
|
836
|
+
sandbox=settings.get("sandbox", "workspace-write"),
|
|
837
|
+
web_search=settings.get("web_search"),
|
|
838
|
+
session=resumed_session,
|
|
839
|
+
)
|
|
840
|
+
resumed_cost = 0.0
|
|
841
|
+
started_monotonic = time.monotonic()
|
|
842
|
+
try:
|
|
843
|
+
with harness.build_argv(invocation) as argv:
|
|
844
|
+
completed_process = _spawn(node_run_name, argv, directory, hard_time_limit, spent_time)
|
|
845
|
+
if resumed_session is not None and completed_process.returncode != 0:
|
|
846
|
+
# The resumed spawn counts what it spent, whether or not its output holds a result.
|
|
847
|
+
try:
|
|
848
|
+
resumed_cost = harness.read_result(
|
|
849
|
+
invocation, _read_stdout_lines(directory, node_run_name)
|
|
850
|
+
)[1]
|
|
851
|
+
except NodeFailure as read_failure:
|
|
852
|
+
resumed_cost = read_failure.cost
|
|
853
|
+
_record_fallback(
|
|
854
|
+
directory,
|
|
855
|
+
node_run_name,
|
|
856
|
+
f"node '{agent_node_name}': agent exited with code {completed_process.returncode}",
|
|
857
|
+
)
|
|
858
|
+
invocation = replace(
|
|
859
|
+
invocation,
|
|
860
|
+
session=None,
|
|
861
|
+
prompt=_build_agent_message(agent_node_name, run_input, handoff, None),
|
|
862
|
+
)
|
|
863
|
+
with harness.build_argv(invocation) as argv:
|
|
864
|
+
completed_process = _spawn(
|
|
865
|
+
node_run_name,
|
|
866
|
+
argv,
|
|
867
|
+
directory,
|
|
868
|
+
hard_time_limit,
|
|
869
|
+
spent_time + time.monotonic() - started_monotonic,
|
|
870
|
+
)
|
|
871
|
+
if completed_process.returncode != 0:
|
|
872
|
+
raise NodeFailure(
|
|
873
|
+
f"node '{agent_node_name}': agent exited with code {completed_process.returncode}"
|
|
874
|
+
)
|
|
875
|
+
stdout_lines = _read_stdout_lines(directory, node_run_name)
|
|
876
|
+
structured_output, cost = harness.read_result(invocation, stdout_lines)
|
|
877
|
+
if (
|
|
878
|
+
not isinstance(structured_output, dict)
|
|
879
|
+
or structured_output.get("outcome") not in node_definition["outcomes"]
|
|
880
|
+
):
|
|
881
|
+
raise NodeFailure(
|
|
882
|
+
f"node '{agent_node_name}': agent reported no outcome from {node_definition['outcomes']}",
|
|
883
|
+
cost,
|
|
884
|
+
)
|
|
885
|
+
handoff_text = structured_output.get("handoff")
|
|
886
|
+
return NodeRunResult(
|
|
887
|
+
structured_output["outcome"],
|
|
888
|
+
str(handoff_text) if handoff_text is not None else None,
|
|
889
|
+
cost + resumed_cost,
|
|
890
|
+
harness.read_session(stdout_lines),
|
|
891
|
+
)
|
|
892
|
+
except NodeFailure as error:
|
|
893
|
+
error.cost += resumed_cost
|
|
894
|
+
error.session = harness.read_session(_read_stdout_lines(directory, node_run_name))
|
|
895
|
+
raise
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _load_agent_definition(agent_node_name: str, agent_name: str) -> dict[str, str]:
|
|
899
|
+
for definition_directory in list_definition_directories():
|
|
900
|
+
path = definition_directory / "agents" / f"{agent_name}.md"
|
|
901
|
+
if path.is_file():
|
|
902
|
+
return _parse_agent_definition(path.read_text())
|
|
903
|
+
raise NodeFailure(
|
|
904
|
+
f"node '{agent_node_name}': agent definition '{agent_name}' not found in .workgraph/agents"
|
|
905
|
+
" of the invocation directory or the home directory, nor among the bundled agents"
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _parse_agent_definition(definition_text: str) -> dict[str, str]:
|
|
910
|
+
front_matter, separator, body = definition_text.removeprefix("---\n").partition("\n---\n")
|
|
911
|
+
if not definition_text.startswith("---\n") or not separator:
|
|
912
|
+
return {"prompt": definition_text}
|
|
913
|
+
# ponytail: single-line "key: value" pairs only; a YAML parser when a definition needs more.
|
|
914
|
+
definition_fields = {"prompt": body.lstrip("\n")}
|
|
915
|
+
for line in front_matter.splitlines():
|
|
916
|
+
key, colon, value = line.partition(":")
|
|
917
|
+
if colon:
|
|
918
|
+
definition_fields[key.strip()] = value.strip()
|
|
919
|
+
return definition_fields
|
|
920
|
+
|
|
921
|
+
|
|
922
|
+
def _write_state(
|
|
923
|
+
state: dict[str, Any],
|
|
924
|
+
directory: Path,
|
|
925
|
+
node_name: str,
|
|
926
|
+
handoff: tuple[str, str] | None,
|
|
927
|
+
stop_reason: str | None = None,
|
|
928
|
+
error_message: str | None = None,
|
|
929
|
+
) -> None:
|
|
930
|
+
state.update(
|
|
931
|
+
node=node_name,
|
|
932
|
+
handoff=list(handoff) if handoff else None,
|
|
933
|
+
stopped=stop_reason,
|
|
934
|
+
reason=error_message,
|
|
935
|
+
)
|
|
936
|
+
_save_state(state, directory)
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
def _save_state(state: dict[str, Any], directory: Path) -> None:
|
|
940
|
+
"""Write the state to STATE_FILE; None-valued keys are dropped."""
|
|
941
|
+
written_state = {key: value for key, value in state.items() if value is not None}
|
|
942
|
+
(directory / STATE_FILE).write_text(json.dumps(written_state))
|