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.
- nightfall/__init__.py +0 -0
- nightfall/agent.py +88 -0
- nightfall/commands.py +78 -0
- nightfall/compact.py +132 -0
- nightfall/config.py +21 -0
- nightfall/context.py +77 -0
- nightfall/history.py +127 -0
- nightfall/llm.py +90 -0
- nightfall/permissions.py +120 -0
- nightfall/prompt.py +61 -0
- nightfall/sandbox.py +210 -0
- nightfall/session.py +86 -0
- nightfall/skills.py +34 -0
- nightfall/subagent.py +166 -0
- nightfall/todos.py +66 -0
- nightfall/tools.py +198 -0
- nightfall/ui.py +290 -0
- nightfall_cli-0.1.0.dist-info/METADATA +58 -0
- nightfall_cli-0.1.0.dist-info/RECORD +21 -0
- nightfall_cli-0.1.0.dist-info/WHEEL +4 -0
- nightfall_cli-0.1.0.dist-info/entry_points.txt +2 -0
nightfall/permissions.py
ADDED
|
@@ -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
|
+
}
|