-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathrun.py
More file actions
293 lines (239 loc) · 12.8 KB
/
Copy pathrun.py
File metadata and controls
293 lines (239 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
#!/usr/bin/env python3
"""Run any of the course's agents from the course root, without going looking for its directory.
python run.py doctor # the no-framework agent
python run.py agentlang doctor # the LangGraph agent
python run.py agentgraph doctor # the LangGraph + reasoning agent
python run.py agentgraph solve tasks/workshop/01-shopcart --verbose
python run.py agentgraph eval --suite workshop
python run.py agentgraph unittest tests.test_task -v
python run.py agentgraph docker-build
Why this exists. The guided project is a JetBrains Academy *framework lesson*, and those keep all
of a lesson's code in one plugin-managed working directory. That directory is real, but it is not
declared course content, so the Course View does not show it: you would have to switch to the
Project Files view and walk down four levels to get a terminal in the right place, on every
single task. This script finds that directory for you and runs the command there, so the terminal
you already have at the course root is enough.
It shells out rather than importing, deliberately. The course root contains a directory called
`agentfix/` (the course *section*), and Python would happily treat that as a namespace package
and import it instead of the real one. Running a subprocess with the working directory set means
the package name always resolves to the package, never to the section folder.
Three lessons, three agents, three package names. `lesson_build` builds the agent with no
framework and calls its package `agentfix`; `lesson_langchain` rebuilds it on LangGraph and calls
its package `agentlang`; `lesson_react` drives that same graph with a *thinking* model and calls
its package `agentgraph`. They are separate implementations with separate code, so every
command — solve, eval, doctor, unittest, docker-build — has to run against one of them.
Which one? Nothing on disk tells this script which lesson you are reading, so it is chosen
explicitly, in this order:
1. the first word of the command `python run.py agentgraph solve ...`
2. a flag, if you prefer it `python run.py --agentgraph solve ...`
3. AGENT_EDITION in .agentfix.env set it once while working through a lesson
4. the default, `agentfix`
The `[run.py]` line printed before every command names the directory it chose, so which agent
ran is never a guess.
"""
from __future__ import annotations
import os
import subprocess
import sys
from dataclasses import dataclass
from pathlib import Path
ROOT = Path(__file__).resolve().parent
# Written by `python setup.py`: the model the chosen tier installed. It lives in a file because a
# child process cannot set its parent shell's environment, so `setup.py` has no way to hand the
# variable back to the terminal you are standing in. Reading it here means the choice survives a
# new terminal without anyone having to remember an `export` line.
#
# Shared by every edition, and deliberately still called `.agentfix.env` — `setup.py` writes it,
# and the tier it records is a property of the machine, not of which lesson you are running.
# `AGENT_EDITION` is also read from here, which is what makes step 3 above work.
ENV_FILE = ROOT / ".agentfix.env"
EDITION_KEY = "AGENT_EDITION"
# The tag `docker-build` writes, per edition, because it has to match what that edition's
# `docker_backend.py` goes looking for. `agentfix` and `agentlang` both expect
# `agentfix-sandbox:latest`; `agentgraph` renamed its image along with its package and expects
# `agentgraph-sandbox:latest`. Building the wrong tag fails at solve time, not at build time,
# with "Unable to find image" — so this is derived per edition rather than shared.
DEFAULT_SANDBOX_IMAGE = "agentfix-sandbox"
@dataclass(frozen=True)
class Edition:
"""One of the course's agents: where its code lives, and what its package is called."""
name: str # what you type: `python run.py <name> <command>` or `--<name>`
package: str # the importable package, for `python -m <package>.cli`
lesson: str # the lesson it belongs to, named in errors
workdirs: tuple[Path, ...] # probed in order; the first one holding the package wins
tests_workdir: Path | None # where `unittest` runs, if this edition ships a suite
sandbox_image: str = DEFAULT_SANDBOX_IMAGE # the tag `docker-build` writes for this edition
def find_workdir(self) -> Path:
"""The directory this edition's code actually lives in."""
for candidate in self.workdirs:
if (candidate / self.package / "cli.py").is_file():
return candidate
searched = "\n ".join(str(p) for p in self.workdirs)
raise SystemExit(
f"run.py could not find the {self.package} code. Looked in:\n {searched}\n\n"
f"If you are a learner: open the '{self.lesson}' lesson once — the working directory\n"
"is created when you first open one of its steps. Then try again."
)
AGENTFIX = Edition(
name="agentfix",
package="agentfix",
lesson="Agent with no Framework",
# `run_for_real` is the finished no-framework agent, so it is the copy worth running: every
# earlier step in the lesson is the same code with pieces taken out. `task/` is the
# plugin-managed directory a learner's project materialises, and it does not exist in the
# authoring repo — probed first so a learner runs their own work rather than the solution.
workdirs=(
ROOT / "agentfix" / "lesson_build" / "task",
ROOT / "agentfix" / "lesson_build" / "run_for_real",
),
# Both steps of this lesson are theory tasks and neither ships a suite. See TESTS_FALLBACK.
tests_workdir=None,
)
AGENTLANG = Edition(
name="agentlang",
package="agentlang",
lesson="What about frameworks?",
workdirs=(
ROOT / "agentfix" / "lesson_langchain" / "task",
ROOT / "agentfix" / "lesson_langchain" / "real",
),
# `stage_2` is where the project is complete, so that is where the whole suite lives —
# the graph, its state, the stop condition, the budget and guard, the wiring and the oracle.
tests_workdir=ROOT / "agentfix" / "lesson_langchain" / "stage_2",
)
AGENTGRAPH = Edition(
name="agentgraph",
package="agentgraph",
lesson="What about thinking?",
# Same shape as the other two: the learner's own materialised directory first, then the
# authoring copy of the finished project. `real_react` is that copy — the step where the
# agent is run against a real thinking model — and it holds the same package as `react`.
workdirs=(
ROOT / "agentfix" / "lesson_react" / "task",
ROOT / "agentfix" / "lesson_react" / "real_react",
ROOT / "agentfix" / "lesson_react" / "react",
),
# `react` is the step that ships the suite: the exercise, the graph, reasoning, the tools,
# the sandbox, the oracle and the shipped fixtures, in one file.
tests_workdir=ROOT / "agentfix" / "lesson_react" / "react",
sandbox_image="agentgraph-sandbox",
)
EDITIONS: dict[str, Edition] = {
AGENTFIX.name: AGENTFIX,
AGENTLANG.name: AGENTLANG,
AGENTGRAPH.name: AGENTGRAPH,
}
# What you get when you name nothing, so every command that worked before this file learned
# about a second and third edition still works unchanged.
DEFAULT_EDITION = AGENTFIX.name
# `unittest` under an edition that ships no suite runs this one instead, rather than failing with
# `No module named 'tests'`. The printed `[run.py]` line names the directory, so the substitution
# is visible rather than silent.
TESTS_FALLBACK = AGENTLANG
USAGE = """usage: python run.py [edition] <command> [args...]
python run.py doctor
python run.py solve tasks/workshop/01-shopcart --verbose
python run.py eval --suite workshop --limit 3
python run.py unittest tests.test_task -v
python run.py docker-build
Any other command is passed straight through to `python -m <package>.cli`.
editions — pick one as the first word, or as --<name>, or set {key} in {env}:
{editions}
default: {default}"""
def usage() -> str:
listed = "\n".join(
f" {e.name:<11} {e.lesson:<26} python -m {e.package}.cli" for e in EDITIONS.values()
)
return USAGE.format(
key=EDITION_KEY, env=ENV_FILE.name, editions=listed, default=DEFAULT_EDITION
)
def env_from_file() -> dict[str, str]:
"""Settings from `.agentfix.env`, minus anything already set for real.
A real environment variable always wins over the file: the file is the default that setup
chose, not an override of what you asked for in this shell.
"""
if not ENV_FILE.is_file():
return {}
try:
text = ENV_FILE.read_text(encoding="utf-8")
except (OSError, UnicodeDecodeError) as error:
# UnicodeDecodeError is a ValueError, not an OSError — a file with a stray non-UTF-8
# byte in it must not take down every command that goes through run.py.
print(f"[run.py] ignoring {ENV_FILE.name}: {error}", file=sys.stderr)
return {}
overrides: dict[str, str] = {}
for line in text.splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = (part.strip() for part in line.split("=", 1))
if key and key not in os.environ:
overrides[key] = value
return overrides
def pick_edition(argv: list[str], overrides: dict[str, str]) -> tuple[Edition, list[str]]:
"""Resolve which agent to run, and hand back the arguments with the choice removed.
Only a name that is actually registered counts, so `run.py solve ...` keeps working and a
typo stays an unknown *command* for the CLI to report rather than becoming a silently
different agent.
"""
if argv and argv[0] in EDITIONS:
return EDITIONS[argv[0]], argv[1:]
if argv and argv[0].startswith("--") and argv[0][2:] in EDITIONS:
return EDITIONS[argv[0][2:]], argv[1:]
# A real environment variable beats the file, same rule as every other setting.
chosen = os.environ.get(EDITION_KEY) or overrides.get(EDITION_KEY)
if chosen:
if chosen not in EDITIONS:
known = ", ".join(EDITIONS)
raise SystemExit(f"{EDITION_KEY}={chosen!r} is not an edition. Known: {known}")
return EDITIONS[chosen], argv
return EDITIONS[DEFAULT_EDITION], argv
def tests_workdir_for(edition: Edition) -> Path:
"""Where `unittest` should run for this edition."""
if edition.tests_workdir is not None:
return edition.tests_workdir
if TESTS_FALLBACK.tests_workdir is None: # pragma: no cover - guards a future edit
raise SystemExit("run.py has no edition that ships a test suite.")
print(
f"[run.py] {edition.package} ships no tests; running the {TESTS_FALLBACK.package} suite",
file=sys.stderr,
)
return TESTS_FALLBACK.tests_workdir
def build_command(edition: Edition, argv: list[str]) -> tuple[list[str], Path]:
"""The subprocess to run, and the directory to run it in."""
# `unittest` is resolved before the agent's code is located: the suite lives with the
# finished project, which is not necessarily the copy this edition would solve tasks from.
if argv[0] == "unittest":
workdir = tests_workdir_for(edition)
if not (workdir / "tests").is_dir():
raise SystemExit(f"run.py could not find a tests/ directory in:\n {workdir}")
return [sys.executable, "-m", "unittest", *argv[1:]], workdir
workdir = edition.find_workdir()
if argv[0] == "docker-build":
# Built from the edition's own directory rather than a path spelled out up here, so
# renaming or reordering tasks cannot leave this pointing at a directory that is gone.
if not (workdir / "Dockerfile.sandbox").is_file():
raise SystemExit(f"run.py could not find Dockerfile.sandbox in:\n {workdir}")
argv = ["docker", "build", "-t", edition.sandbox_image, "-f", "Dockerfile.sandbox", "."]
return argv, workdir
# Everything else — doctor, solve, eval, --version — is the edition's own CLI.
return [sys.executable, "-m", f"{edition.package}.cli", *argv], workdir
def main(argv: list[str]) -> int:
if not argv or argv[0] in {"-h", "--help", "help"}:
print(usage())
return 0
overrides = env_from_file()
edition, argv = pick_edition(argv, overrides)
if not argv:
print(usage())
return 0
command, workdir = build_command(edition, argv)
settings = "".join(f"{key}={value} " for key, value in sorted(overrides.items()))
print(
f"[run.py] {edition.name}: {workdir.relative_to(ROOT)} $ "
f"{settings}{' '.join(command)}\n",
flush=True,
)
return subprocess.call(command, cwd=workdir, env={**os.environ, **overrides})
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))