nightfall-cli 0.1.0__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.
@@ -0,0 +1,120 @@
1
+ """Which tool calls need a human.
2
+
3
+ The sandbox decides what is *possible*. These rules only decide what is worth
4
+ interrupting you for - so read-only commands run silently, and the risky ones
5
+ still stop and ask.
6
+ """
7
+
8
+ from fnmatch import fnmatch
9
+ from pathlib import Path
10
+
11
+ PROJECT = Path.cwd().resolve()
12
+
13
+ # Last matching rule wins, so put the catch-all first.
14
+ BASH_RULES = {
15
+ "*": "ask",
16
+ # read-only: let them through
17
+ "ls*": "allow",
18
+ "pwd": "allow",
19
+ "cd *": "allow",
20
+ "echo *": "allow",
21
+ "sort*": "allow",
22
+ "uniq*": "allow",
23
+ "cut *": "allow",
24
+ "basename *": "allow",
25
+ "dirname *": "allow",
26
+ "date*": "allow",
27
+ "env": "allow",
28
+ "cat *": "allow",
29
+ "head *": "allow",
30
+ "tail *": "allow",
31
+ "wc *": "allow",
32
+ "file *": "allow",
33
+ "which *": "allow",
34
+ "grep *": "allow",
35
+ "rg *": "allow",
36
+ "find *": "allow",
37
+ "tree*": "allow",
38
+ "git status*": "allow",
39
+ "git diff*": "allow",
40
+ "git log*": "allow",
41
+ "git show*": "allow",
42
+ "git ls-files*": "allow",
43
+ "pytest*": "allow",
44
+ "python -m pytest*": "allow",
45
+ # risky: never, even if the user says yes
46
+ "rm *": "deny",
47
+ "sudo *": "deny",
48
+ "chmod *": "deny",
49
+ "chown *": "deny",
50
+ "curl *": "deny",
51
+ "wget *": "deny",
52
+ "git push*": "deny",
53
+ "git reset*": "deny",
54
+ "git clean*": "deny",
55
+ }
56
+
57
+ def split_command(command):
58
+ """Split a compound command on the separators that actually separate.
59
+
60
+ A naive split on | and ; also cuts inside quotes, so `rg "cap|max"` breaks
61
+ into fragments that match no rule and fall through to "ask". Anything
62
+ quoted or backslash-escaped is an argument, not a separator.
63
+ """
64
+ parts, current, quote = [], [], None
65
+ index = 0
66
+ while index < len(command):
67
+ char = command[index]
68
+ if quote:
69
+ current.append(char)
70
+ quote = None if char == quote else quote
71
+ elif char == "\\":
72
+ current.append(char)
73
+ index += 1
74
+ if index < len(command):
75
+ current.append(command[index])
76
+ elif char in "\"'":
77
+ quote = char
78
+ current.append(char)
79
+ elif char in "&|;":
80
+ parts.append("".join(current))
81
+ current = []
82
+ while index + 1 < len(command) and command[index + 1] in "&|":
83
+ index += 1
84
+ else:
85
+ current.append(char)
86
+ index += 1
87
+
88
+ parts.append("".join(current))
89
+ return [part.strip() for part in parts if part.strip()]
90
+
91
+
92
+ def decide(command):
93
+ """Rate every part of a compound command; the strictest verdict wins."""
94
+ verdicts = []
95
+ for part in split_command(command):
96
+ action = "ask"
97
+ for pattern, rule in BASH_RULES.items():
98
+ if fnmatch(part, pattern):
99
+ action = rule
100
+ verdicts.append(action)
101
+
102
+ for strictest in ("deny", "ask"):
103
+ if strictest in verdicts:
104
+ return strictest
105
+ return "allow"
106
+
107
+
108
+ def inside_project(path):
109
+ return PROJECT in Path(path).resolve().parents
110
+
111
+
112
+ def check(name, args):
113
+ """Return (action, reason). Action is allow, ask or deny."""
114
+ if name == "bash":
115
+ return decide(args["command"]), f"run: {args['command']}"
116
+
117
+ if name in ("write_file", "str_replace") and not inside_project(args["path"]):
118
+ return "ask", f"{name} outside {PROJECT}: {args['path']}"
119
+
120
+ return "allow", None
nightfall/prompt.py ADDED
@@ -0,0 +1,61 @@
1
+ """The input line.
2
+
3
+ `input()` cannot edit a line that has wrapped past the screen width - the
4
+ terminal owns the wrapping and readline cannot see it. prompt_toolkit redraws
5
+ the line itself, so deleting, word jumps and history all keep working once the
6
+ text is longer than the screen.
7
+ """
8
+
9
+ from pathlib import Path
10
+
11
+ from prompt_toolkit import PromptSession
12
+ from prompt_toolkit.formatted_text import HTML
13
+ from prompt_toolkit.history import FileHistory
14
+ from prompt_toolkit.key_binding import KeyBindings
15
+ from prompt_toolkit.styles import Style
16
+
17
+ HISTORY = Path.home() / ".agents" / "history"
18
+
19
+ STYLE = Style.from_dict({"prompt": "bold #9ece6a"})
20
+
21
+ bindings = KeyBindings()
22
+
23
+
24
+ # macOS sends option-arrow as escape then arrow. Terminals configured to send
25
+ # option as meta emit alt-b / alt-f instead, which prompt_toolkit binds itself.
26
+ @bindings.add("escape", "left")
27
+ def _word_left(event):
28
+ document = event.current_buffer.document
29
+ event.current_buffer.cursor_position += (
30
+ document.find_previous_word_beginning(count=1) or 0
31
+ )
32
+
33
+
34
+ @bindings.add("escape", "right")
35
+ def _word_right(event):
36
+ document = event.current_buffer.document
37
+ event.current_buffer.cursor_position += (
38
+ document.find_next_word_ending(count=1) or 0
39
+ )
40
+
41
+
42
+ @bindings.add("escape", "enter")
43
+ def _newline(event):
44
+ """Option-enter starts a new line instead of sending the message."""
45
+ event.current_buffer.insert_text("\n")
46
+
47
+
48
+ SESSION = None
49
+
50
+
51
+ def read(prompt="> "):
52
+ """Read one message. Raises EOFError on ctrl-d, like input() does."""
53
+ global SESSION
54
+ if SESSION is None:
55
+ HISTORY.parent.mkdir(parents=True, exist_ok=True)
56
+ SESSION = PromptSession(
57
+ history=FileHistory(str(HISTORY)),
58
+ key_bindings=bindings,
59
+ style=STYLE,
60
+ )
61
+ return SESSION.prompt(HTML(f"<prompt>{prompt}</prompt>"))
nightfall/sandbox.py ADDED
@@ -0,0 +1,210 @@
1
+ """Platform-backed command sandboxing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import ctypes
6
+ import shutil
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ from pathlib import Path
11
+
12
+ PROJECT = Path.cwd().resolve()
13
+
14
+
15
+ class SandboxUnavailable(RuntimeError):
16
+ """Raised when a platform sandbox cannot be initialized."""
17
+
18
+
19
+ class Backend:
20
+ label = "none"
21
+
22
+ def name(self) -> str:
23
+ return self.label
24
+
25
+ def run(self, command: str, timeout: int = 60) -> subprocess.CompletedProcess:
26
+ raise NotImplementedError
27
+
28
+
29
+ class SeatbeltBackend(Backend):
30
+ label = "seatbelt"
31
+
32
+ def __init__(self, project: Path):
33
+ self.project = project
34
+
35
+ def wrap(self, command: str) -> list[str]:
36
+ profile = Path(tempfile.gettempdir()) / "nightfall.sb"
37
+ profile.write_text(
38
+ "(version 1)\n"
39
+ "(deny default)\n"
40
+ "(allow process-exec process-fork signal)\n"
41
+ "(allow file-read*)\n"
42
+ "(allow sysctl-read)\n"
43
+ "(deny network*)\n"
44
+ f'(allow file-write* (subpath "{self.project}") '
45
+ '(literal "/dev/null"))\n'
46
+ f'(deny file-write* (subpath "{self.project / ".git"}"))\n'
47
+ )
48
+ return ["sandbox-exec", "-f", str(profile), "/bin/sh", "-c", command]
49
+
50
+ def run(self, command: str, timeout: int = 60) -> subprocess.CompletedProcess:
51
+ return subprocess.run(
52
+ self.wrap(command), capture_output=True, text=True, timeout=timeout
53
+ )
54
+
55
+
56
+ class BubblewrapBackend(Backend):
57
+ label = "bubblewrap"
58
+
59
+ def __init__(self, project: Path):
60
+ self.project = project
61
+
62
+ def wrap(self, command: str) -> list[str]:
63
+ return [
64
+ "bwrap", "--ro-bind", "/", "/", "--bind", str(self.project), str(self.project),
65
+ "--dev", "/dev", "--proc", "/proc", "--unshare-net", "--die-with-parent",
66
+ "/bin/sh", "-c", command,
67
+ ]
68
+
69
+ def run(self, command: str, timeout: int = 60) -> subprocess.CompletedProcess:
70
+ return subprocess.run(
71
+ self.wrap(command), capture_output=True, text=True, timeout=timeout
72
+ )
73
+
74
+
75
+ class _BasicLimits(ctypes.Structure):
76
+ _fields_ = [
77
+ ("PerProcessUserTimeLimit", ctypes.c_longlong),
78
+ ("PerJobUserTimeLimit", ctypes.c_longlong),
79
+ ("LimitFlags", ctypes.c_uint32),
80
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
81
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
82
+ ("ActiveProcessLimit", ctypes.c_uint32),
83
+ ("Affinity", ctypes.c_size_t),
84
+ ("PriorityClass", ctypes.c_uint32),
85
+ ("SchedulingClass", ctypes.c_uint32),
86
+ ]
87
+
88
+
89
+ class _IoCounters(ctypes.Structure):
90
+ _fields_ = [("value", ctypes.c_ulonglong) for _ in range(6)]
91
+
92
+
93
+ class _ExtendedLimits(ctypes.Structure):
94
+ _fields_ = [
95
+ ("BasicLimitInformation", _BasicLimits),
96
+ ("IoInfo", _IoCounters),
97
+ ("ProcessMemoryLimit", ctypes.c_size_t),
98
+ ("JobMemoryLimit", ctypes.c_size_t),
99
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
100
+ ("PeakJobMemoryUsed", ctypes.c_size_t),
101
+ ]
102
+
103
+
104
+ class WindowsBackend(Backend):
105
+ """Native Windows process containment using Job Objects."""
106
+
107
+ label = "windows-appcontainer"
108
+ JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
109
+ JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9
110
+ CREATE_NEW_PROCESS_GROUP = 0x00000200
111
+ CREATE_NO_WINDOW = 0x08000000
112
+
113
+ def __init__(self, project: Path):
114
+ if sys.platform != "win32":
115
+ raise SandboxUnavailable("Windows sandbox requested on a non-Windows host")
116
+ self.project = project
117
+ try:
118
+ self.kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
119
+ self._configure_api()
120
+ except (AttributeError, OSError) as error:
121
+ raise SandboxUnavailable(
122
+ "Windows native sandbox APIs are unavailable; use a supported Windows runtime."
123
+ ) from error
124
+
125
+ def _configure_api(self):
126
+ self.kernel32.CreateJobObjectW.restype = ctypes.c_void_p
127
+ self.kernel32.SetInformationJobObject.argtypes = [
128
+ ctypes.c_void_p, ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32
129
+ ]
130
+ self.kernel32.AssignProcessToJobObject.argtypes = [ctypes.c_void_p, ctypes.c_void_p]
131
+ self.kernel32.TerminateJobObject.argtypes = [ctypes.c_void_p, ctypes.c_uint32]
132
+ self.kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
133
+
134
+ def wrap(self, command: str) -> list[str]:
135
+ return ["cmd.exe", "/d", "/s", "/c", command]
136
+
137
+ def _job(self):
138
+ handle = self.kernel32.CreateJobObjectW(None, None)
139
+ if not handle:
140
+ raise SandboxUnavailable(f"CreateJobObjectW failed: {ctypes.get_last_error()}")
141
+ limits = _ExtendedLimits()
142
+ limits.BasicLimitInformation.LimitFlags = self.JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE
143
+ if not self.kernel32.SetInformationJobObject(
144
+ handle, self.JOB_OBJECT_EXTENDED_LIMIT_INFORMATION,
145
+ ctypes.byref(limits), ctypes.sizeof(limits)
146
+ ):
147
+ self.kernel32.CloseHandle(handle)
148
+ raise SandboxUnavailable(
149
+ f"SetInformationJobObject failed: {ctypes.get_last_error()}"
150
+ )
151
+ return handle
152
+
153
+ def run(self, command: str, timeout: int = 60) -> subprocess.CompletedProcess:
154
+ job = self._job()
155
+ argv = self.wrap(command)
156
+ try:
157
+ process = subprocess.Popen(
158
+ argv, cwd=str(self.project), stdout=subprocess.PIPE, stderr=subprocess.PIPE,
159
+ text=True, creationflags=self.CREATE_NEW_PROCESS_GROUP | self.CREATE_NO_WINDOW,
160
+ )
161
+ if not self.kernel32.AssignProcessToJobObject(job, ctypes.c_void_p(process._handle)):
162
+ process.kill()
163
+ raise SandboxUnavailable(
164
+ f"AssignProcessToJobObject failed: {ctypes.get_last_error()}"
165
+ )
166
+ try:
167
+ stdout, stderr = process.communicate(timeout=timeout)
168
+ except subprocess.TimeoutExpired:
169
+ self.kernel32.TerminateJobObject(job, 1)
170
+ stdout, stderr = process.communicate()
171
+ raise subprocess.TimeoutExpired(timeout, argv, stdout, stderr)
172
+ return subprocess.CompletedProcess(argv, process.returncode, stdout, stderr)
173
+ finally:
174
+ self.kernel32.CloseHandle(job)
175
+
176
+
177
+ class UnavailableBackend(Backend):
178
+ label = "unavailable"
179
+
180
+ def run(self, command: str, timeout: int = 60) -> subprocess.CompletedProcess:
181
+ raise SandboxUnavailable(
182
+ "No supported OS sandbox is available. Install Bubblewrap on Linux "
183
+ "or use a supported macOS/Windows runtime."
184
+ )
185
+
186
+
187
+ def _select_backend() -> Backend:
188
+ if sys.platform == "darwin":
189
+ return SeatbeltBackend(PROJECT)
190
+ if sys.platform.startswith("linux") and shutil.which("bwrap"):
191
+ return BubblewrapBackend(PROJECT)
192
+ if sys.platform == "win32":
193
+ return WindowsBackend(PROJECT)
194
+ return UnavailableBackend()
195
+
196
+
197
+ BACKEND = _select_backend()
198
+
199
+
200
+ def name() -> str:
201
+ return BACKEND.name()
202
+
203
+
204
+ def wrap(command: str):
205
+ wrapper = getattr(BACKEND, "wrap", None)
206
+ return wrapper(command) if wrapper else None
207
+
208
+
209
+ def run(command: str, timeout: int = 60) -> subprocess.CompletedProcess:
210
+ return BACKEND.run(command, timeout)
nightfall/session.py ADDED
@@ -0,0 +1,86 @@
1
+ """Transcripts on disk. One JSONL file per chat."""
2
+
3
+ import json
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ PROJECT = str(Path.cwd().resolve()).replace("/", "-")
8
+ SESSION_DIR = Path.home() / ".agents" / "sessions" / PROJECT
9
+ CURRENT = datetime.now().strftime("%Y%m%d-%H%M%S")
10
+ WRITTEN = 0 # how many messages are already on disk
11
+
12
+
13
+ def path_for(session_id):
14
+ return SESSION_DIR / f"{session_id}.jsonl"
15
+
16
+
17
+ def save(messages):
18
+ """Append what is new. Never rewrite what is already on disk."""
19
+ global WRITTEN
20
+ SESSION_DIR.mkdir(parents=True, exist_ok=True)
21
+ with path_for(CURRENT).open("a") as f:
22
+ for message in messages[WRITTEN:]:
23
+ f.write(json.dumps(message) + "\n")
24
+ WRITTEN = len(messages)
25
+
26
+
27
+ def rewind_to(count):
28
+ """Record a rewind as an entry, so the old messages stay in the file."""
29
+ global WRITTEN
30
+ with path_for(CURRENT).open("a") as f:
31
+ f.write(json.dumps({"rewind_to": count}) + "\n")
32
+ WRITTEN = count
33
+
34
+
35
+ def compacted(messages):
36
+ """Compaction rewrites history, so record the result and start from it."""
37
+ global WRITTEN
38
+ with path_for(CURRENT).open("a") as f:
39
+ f.write(json.dumps({"compacted": messages}) + "\n")
40
+ WRITTEN = len(messages)
41
+
42
+
43
+ def load(session_id):
44
+ """Replay the log: messages accumulate, rewinds cut them back."""
45
+ messages = []
46
+ for line in path_for(session_id).read_text().splitlines():
47
+ try:
48
+ entry = json.loads(line)
49
+ except json.JSONDecodeError:
50
+ # A half-written last line, usually from a kill mid-save. Skipping
51
+ # it costs one message; raising would break /sessions for every
52
+ # chat in the project, because listing them all calls load().
53
+ continue
54
+ if "rewind_to" in entry:
55
+ del messages[entry["rewind_to"]:]
56
+ elif "compacted" in entry:
57
+ messages = list(entry["compacted"])
58
+ else:
59
+ messages.append(entry)
60
+ return messages
61
+
62
+
63
+ def open_session(session_id):
64
+ """Switch to a past chat and become it."""
65
+ global CURRENT, WRITTEN
66
+ CURRENT = session_id
67
+ messages = load(session_id)
68
+ WRITTEN = len(messages)
69
+ return messages
70
+
71
+
72
+ def title(messages):
73
+ for message in messages:
74
+ if message["role"] == "user":
75
+ return " ".join(str(message.get("content") or "").split())[:60]
76
+ return "(empty)"
77
+
78
+
79
+ def all_sessions():
80
+ """Newest first."""
81
+ if not SESSION_DIR.exists():
82
+ return []
83
+ files = sorted(
84
+ SESSION_DIR.glob("*.jsonl"), key=lambda p: p.stat().st_mtime, reverse=True
85
+ )
86
+ return [{"id": p.stem, "title": title(load(p.stem))} for p in files]
nightfall/skills.py ADDED
@@ -0,0 +1,34 @@
1
+ from pathlib import Path
2
+
3
+ import yaml
4
+
5
+ SKILL_DIRS = [
6
+ Path.cwd() / ".agents" / "skills",
7
+ Path.home() / ".agents" / "skills",
8
+ ]
9
+
10
+
11
+ def find_skills():
12
+ """Map each skill name to its description and SKILL.md path."""
13
+ skills = {}
14
+ for directory in SKILL_DIRS:
15
+ for path in sorted(directory.glob("*/SKILL.md")):
16
+ _, frontmatter, _ = path.read_text().split("---", 2)
17
+ meta = yaml.safe_load(frontmatter)
18
+ description = " ".join(meta["description"].split())
19
+ skills[meta["name"]] = {"description": description, "path": path}
20
+ return skills
21
+
22
+
23
+ SKILLS = find_skills()
24
+
25
+
26
+ def skills_prompt():
27
+ return "\n".join(f"- {name}: {s['description']}" for name, s in SKILLS.items())
28
+
29
+
30
+ def read_skill(name: str) -> str:
31
+ """Open a skill and return its full instructions."""
32
+ if name not in SKILLS:
33
+ return f"No skill named '{name}'."
34
+ return SKILLS[name]["path"].read_text()
nightfall/subagent.py ADDED
@@ -0,0 +1,166 @@
1
+ """Subagents: exploration that happens somewhere else.
2
+
3
+ A subagent is a whole agent loop with its own message list. That list is never
4
+ shown to the main agent and never outlives the call - the only thing that
5
+ crosses back is the final report.
6
+
7
+ That is the entire point, and it is compact.py's idea from the other end.
8
+ Exploring a repo burns tens of thousands of tokens of tool output to produce a
9
+ few hundred tokens of answer. compact.py throws context away after it has been
10
+ spent; a subagent spends it somewhere that gets thrown away by design, so the
11
+ main transcript never pays for the difference at all.
12
+
13
+ Four rules, and the code below is really just these:
14
+
15
+ 1. it starts from an empty history (no memory of anything)
16
+ 2. it holds every tool but two (no recursion, no touching the plan)
17
+ 3. it runs the same loop as the main agent (nothing special happens here)
18
+ 4. only its last message comes back (the rest is discarded)
19
+ """
20
+
21
+ import os
22
+
23
+ MAX_TURNS = 12 # a runaway explorer is worse than a missing answer
24
+
25
+
26
+ # --- rule 2 -----------------------------------------------------------------
27
+ # `task` would let a subagent spawn subagents, forever. `write_todos` writes to
28
+ # a module global that belongs to the main agent's plan, and this one is a
29
+ # guest in someone else's session. Everything else it gets.
30
+ #
31
+ # Note this is a *structural* guarantee - those tools are simply not in the
32
+ # list it is offered, so it cannot call them. The "do not edit files" rule in
33
+ # the prompt below is only asking nicely. To make that one structural too, add
34
+ # write_file and str_replace to this set.
35
+ WITHHELD = {"task", "write_todos", "str_replace", "write"}
36
+
37
+
38
+ SYSTEM_PROMPT = f"""
39
+ You are an exploration subagent. You were given one question by a lead agent
40
+ and you answer it. That is the whole job.
41
+
42
+ You cannot see the conversation that spawned you, and the lead agent cannot
43
+ see anything you do here. Only your final message crosses back, so it has to
44
+ stand on its own.
45
+
46
+ You are working in {os.getcwd()}. Search inside it. Never search from / or
47
+ from the home directory - that scans the whole machine and will time out.
48
+
49
+ How to work:
50
+ - Use bash, read_file and read_skill to find out what is actually true.
51
+ Prefer rg, grep and find to guess at where things live.
52
+ - You are here to read and report, not to change anything. Do not write or
53
+ edit files, and do not run commands with side effects.
54
+ - Search in batches. Several greps in one turn beats one grep per turn.
55
+ - Stop as soon as you can answer. Do not keep looking to be thorough.
56
+
57
+ Your final message is the entire report, and it is the only thing that costs
58
+ the lead agent anything - so keep it short. Aim for under 150 words. Findings
59
+ only: file paths with line numbers, names, values. No preamble, no restating
60
+ the question, no long code blocks - cite the path and line and let the lead
61
+ agent open it. Say plainly what you could not find; a gap is useful, a guess
62
+ is not.
63
+ """
64
+
65
+
66
+ def toolset():
67
+ """Every tool except the ones a guest should not hold."""
68
+ from .tools import TOOL_SCHEMAS
69
+
70
+ return [s for s in TOOL_SCHEMAS if s["function"]["name"] not in WITHHELD]
71
+
72
+
73
+ def task(description: str) -> str:
74
+ """Run a fresh agent on one question and return only its final answer."""
75
+ # Imported inside the function, not at the top: llm imports tools, and
76
+ # tools imports us, so importing them up there would close the circle.
77
+ from .history import fit
78
+ from .llm import call_llm
79
+ from .tools import execute
80
+ from .ui import ui
81
+
82
+ # --- rule 1 ---
83
+ # Two messages. Not a copy of the caller's transcript, not a trimmed
84
+ # version of it, and not whatever the last subagent left behind - this
85
+ # list is born here and dies at the return statement.
86
+ messages = [
87
+ {"role": "system", "content": SYSTEM_PROMPT},
88
+ {"role": "user", "content": description},
89
+ ]
90
+ ui.subagent(description)
91
+
92
+ report = None # newest thing it has said, kept in case we run out of turns
93
+
94
+ # --- rule 3 ---
95
+ # Compare this with the inner loop in agent.py: call, append, run the
96
+ # tools, append, repeat. A subagent is not a new kind of thing. It is the
97
+ # loop you already have, pointed at a different list of messages.
98
+ for _ in range(MAX_TURNS):
99
+ fit(messages) # its context can overflow too, and nobody compacts it
100
+
101
+ with ui.working("subagent exploring"):
102
+ message, usage = call_llm(messages, tools=toolset())
103
+
104
+ messages.append(message.model_dump(exclude_none=True))
105
+ ui.usage(usage)
106
+ report = message.content or report
107
+
108
+ # --- rule 4 ---
109
+ # No tool calls means it has stopped looking and started answering.
110
+ # `messages` goes out of scope on this line - every tool result it
111
+ # gathered, every path it explored, all of it. One string comes back.
112
+ if not message.tool_calls:
113
+ return report or "(the subagent came back with nothing)"
114
+
115
+ for tool_call in message.tool_calls:
116
+ # The same executor the main loop uses, so the same permission
117
+ # rules and the same sandbox apply. A subagent is a second caller,
118
+ # not a privileged one - it is not a way around any of that.
119
+ args, result = execute(tool_call)
120
+ ui.tool(tool_call.function.name, args, result, nested=True)
121
+ messages.append({
122
+ "role": "tool",
123
+ "tool_call_id": tool_call.id,
124
+ "content": result,
125
+ })
126
+
127
+ # Out of turns. Hand back whatever it last managed to say rather than
128
+ # nothing at all - a partial finding still beats making the lead agent
129
+ # start the whole search again from scratch.
130
+ if report:
131
+ return (
132
+ f"(stopped after {MAX_TURNS} turns, before finishing. Partial "
133
+ f"findings below - narrow the question and ask again.)\n\n{report}"
134
+ )
135
+ return f"(stopped after {MAX_TURNS} turns with nothing to report.)"
136
+
137
+
138
+ TASK_SCHEMA = {
139
+ "type": "function",
140
+ "function": {
141
+ "name": "task",
142
+ "description": (
143
+ "Hand a self-contained exploration question to a fresh agent that "
144
+ "has its own context window, and get back its findings. Use this "
145
+ "to learn how the codebase works - tracing behaviour, locating "
146
+ "where something is implemented, surveying files - so the search "
147
+ "costs you one answer instead of dozens of tool results. It cannot "
148
+ "see this conversation, so include every detail it needs. It reads "
149
+ "and reports; it never edits. Do your own editing."
150
+ ),
151
+ "parameters": {
152
+ "type": "object",
153
+ "properties": {
154
+ "description": {
155
+ "type": "string",
156
+ "description": (
157
+ "The question, written to stand alone: what to find "
158
+ "out, where to start looking, and what the answer "
159
+ "should contain."
160
+ ),
161
+ }
162
+ },
163
+ "required": ["description"],
164
+ },
165
+ },
166
+ }