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/__init__.py
ADDED
|
File without changes
|
nightfall/agent.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
|
|
3
|
+
from . import commands
|
|
4
|
+
from . import compact
|
|
5
|
+
from . import history
|
|
6
|
+
from . import session
|
|
7
|
+
from .context import reminder
|
|
8
|
+
from .llm import SYSTEM_PROMPT, call_llm
|
|
9
|
+
from . import sandbox
|
|
10
|
+
from .todos import active_form
|
|
11
|
+
from .tools import execute
|
|
12
|
+
from .ui import ui
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def main():
|
|
16
|
+
parser = argparse.ArgumentParser()
|
|
17
|
+
parser.add_argument("--resume", action="store_true", help="continue the last session")
|
|
18
|
+
parser.add_argument("--debug", action="store_true", help="show the raw model response")
|
|
19
|
+
cli = parser.parse_args()
|
|
20
|
+
|
|
21
|
+
ui.banner(sandbox.name())
|
|
22
|
+
|
|
23
|
+
messages = [{"role": "system", "content": SYSTEM_PROMPT}]
|
|
24
|
+
if cli.resume:
|
|
25
|
+
saved = session.all_sessions()
|
|
26
|
+
if saved:
|
|
27
|
+
messages = session.open_session(saved[0]["id"])
|
|
28
|
+
history.strip(messages)
|
|
29
|
+
ui.resumed(messages)
|
|
30
|
+
ui.replay(messages)
|
|
31
|
+
|
|
32
|
+
while True:
|
|
33
|
+
user_input = ui.ask()
|
|
34
|
+
if not user_input:
|
|
35
|
+
break
|
|
36
|
+
|
|
37
|
+
if user_input.startswith("/"):
|
|
38
|
+
messages = commands.handle(user_input, messages)
|
|
39
|
+
session.save(messages)
|
|
40
|
+
continue
|
|
41
|
+
|
|
42
|
+
messages.append({"role": "user", "content": user_input})
|
|
43
|
+
|
|
44
|
+
while True:
|
|
45
|
+
injection = reminder()
|
|
46
|
+
ui.injection(injection["content"])
|
|
47
|
+
|
|
48
|
+
if history.fit(messages):
|
|
49
|
+
ui.note("dropped old tool output to make this request fit")
|
|
50
|
+
|
|
51
|
+
with ui.working(active_form()):
|
|
52
|
+
message, usage = call_llm(messages + [injection])
|
|
53
|
+
|
|
54
|
+
messages.append(message.model_dump(exclude_none=True))
|
|
55
|
+
session.save(messages)
|
|
56
|
+
ui.usage(usage)
|
|
57
|
+
|
|
58
|
+
if cli.debug:
|
|
59
|
+
ui.debug(message.model_dump(exclude_none=True))
|
|
60
|
+
|
|
61
|
+
if message.content:
|
|
62
|
+
ui.agent(message.content)
|
|
63
|
+
|
|
64
|
+
if not message.tool_calls:
|
|
65
|
+
break
|
|
66
|
+
|
|
67
|
+
for tool_call in message.tool_calls:
|
|
68
|
+
args, result = execute(tool_call)
|
|
69
|
+
ui.tool(tool_call.function.name, args, result)
|
|
70
|
+
|
|
71
|
+
messages.append({
|
|
72
|
+
"role": "tool",
|
|
73
|
+
"tool_call_id": tool_call.id,
|
|
74
|
+
"content": result,
|
|
75
|
+
})
|
|
76
|
+
session.save(messages)
|
|
77
|
+
|
|
78
|
+
history.sweep() # the turn is over: bin its temp files
|
|
79
|
+
history.strip(messages) # ...and shrink the tool output it produced
|
|
80
|
+
|
|
81
|
+
if compact.needed(usage):
|
|
82
|
+
messages = commands.compact(messages)
|
|
83
|
+
|
|
84
|
+
ui.summary()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
if __name__ == "__main__":
|
|
88
|
+
main()
|
nightfall/commands.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Slash commands. Anything typed starting with / lands here."""
|
|
2
|
+
|
|
3
|
+
from . import compact as compaction
|
|
4
|
+
from . import sandbox
|
|
5
|
+
from . import session
|
|
6
|
+
from .ui import ui
|
|
7
|
+
|
|
8
|
+
COMMANDS = {
|
|
9
|
+
"/rewind": "jump back to an earlier point in this chat",
|
|
10
|
+
"/sessions": "open a past chat",
|
|
11
|
+
"/compact": "summarise the history so far and free up the context window",
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def preview(message):
|
|
16
|
+
if message.get("tool_calls"):
|
|
17
|
+
return "-> " + message["tool_calls"][0]["function"]["name"]
|
|
18
|
+
return " ".join(str(message.get("content") or "").split())[:70]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def redraw(messages, label):
|
|
22
|
+
"""The screen no longer matches the history, so wipe it and draw again."""
|
|
23
|
+
ui.clear()
|
|
24
|
+
ui.banner(sandbox.name())
|
|
25
|
+
ui.resumed(messages, label)
|
|
26
|
+
ui.replay(messages)
|
|
27
|
+
return messages
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def rewind(messages):
|
|
31
|
+
rows = [f"{m['role']:<9} {preview(m)}" for m in messages]
|
|
32
|
+
choice = ui.pick("rewind to", rows)
|
|
33
|
+
if choice is None:
|
|
34
|
+
return messages
|
|
35
|
+
session.rewind_to(choice + 1)
|
|
36
|
+
return redraw(messages[: choice + 1], "rewound")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sessions(messages):
|
|
40
|
+
saved = session.all_sessions()
|
|
41
|
+
if not saved:
|
|
42
|
+
ui.note("no saved chats yet")
|
|
43
|
+
return messages
|
|
44
|
+
rows = [f"{s['id']} {s['title']}" for s in saved]
|
|
45
|
+
choice = ui.pick("open chat", rows)
|
|
46
|
+
if choice is None:
|
|
47
|
+
return messages
|
|
48
|
+
|
|
49
|
+
return redraw(session.open_session(saved[choice]["id"]), "opened")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def compact(messages):
|
|
53
|
+
before = len(messages)
|
|
54
|
+
try:
|
|
55
|
+
with ui.working("compacting"):
|
|
56
|
+
compacted = compaction.compact(messages)
|
|
57
|
+
except Exception as failure: # noqa: BLE001
|
|
58
|
+
# Compaction is one more API call, and it fires when the window is
|
|
59
|
+
# nearly full - the worst moment to lose the session over a rate limit.
|
|
60
|
+
ui.note(f"compaction failed ({type(failure).__name__}); transcript kept as is")
|
|
61
|
+
return messages
|
|
62
|
+
if len(compacted) == before:
|
|
63
|
+
ui.note("nothing old enough to compact yet")
|
|
64
|
+
return messages
|
|
65
|
+
session.compacted(compacted)
|
|
66
|
+
ui.compacted(before, compacted)
|
|
67
|
+
return compacted
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def handle(command, messages):
|
|
71
|
+
if command == "/compact":
|
|
72
|
+
return compact(messages)
|
|
73
|
+
if command == "/rewind":
|
|
74
|
+
return rewind(messages)
|
|
75
|
+
if command == "/sessions":
|
|
76
|
+
return sessions(messages)
|
|
77
|
+
ui.note("\n".join(f"{name} - {help}" for name, help in COMMANDS.items()))
|
|
78
|
+
return messages
|
nightfall/compact.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The compaction agent.
|
|
2
|
+
|
|
3
|
+
A second agent with one job: read a transcript that has grown too big and
|
|
4
|
+
write the handoff note a fresh agent would need to carry on.
|
|
5
|
+
|
|
6
|
+
The result replaces the messages it summarised, so this is the only place in
|
|
7
|
+
the codebase that throws information away for good. It runs rarely and cuts
|
|
8
|
+
deep - trimming just enough to fit would put us back over the line next turn,
|
|
9
|
+
and every trim costs the whole prompt cache.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from . import config
|
|
13
|
+
from .history import estimate, strip
|
|
14
|
+
from .llm import client
|
|
15
|
+
|
|
16
|
+
SYSTEM_PROMPT = """
|
|
17
|
+
You are compacting the transcript of a coding session. The session is out of
|
|
18
|
+
context window. Write the handoff note that lets a fresh agent pick the work up
|
|
19
|
+
without re-reading anything.
|
|
20
|
+
|
|
21
|
+
Use these sections, in this order. Skip any that would be empty.
|
|
22
|
+
|
|
23
|
+
## Goal
|
|
24
|
+
What the user asked for. Quote them where the exact wording matters.
|
|
25
|
+
|
|
26
|
+
## What happened
|
|
27
|
+
Decisions taken and the reasoning behind them. Include approaches that were
|
|
28
|
+
tried and abandoned, and why - those are the expensive lessons, and an agent
|
|
29
|
+
without them will try the same dead end again.
|
|
30
|
+
|
|
31
|
+
## Files
|
|
32
|
+
Every file touched: path, and what changed in it.
|
|
33
|
+
|
|
34
|
+
## State
|
|
35
|
+
What works, what is broken, what was left half-finished.
|
|
36
|
+
|
|
37
|
+
## Next
|
|
38
|
+
The immediate next step.
|
|
39
|
+
|
|
40
|
+
Rules:
|
|
41
|
+
- Be specific. Real paths, function names, error text, exact commands.
|
|
42
|
+
- Keep anything the user explicitly asked for, corrected, or rejected.
|
|
43
|
+
- Never invent progress. If something was not finished, say it was not.
|
|
44
|
+
- No preamble and no sign-off. Start at the first heading.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
HANDOFF = """<summary>
|
|
48
|
+
Everything before this point has been compacted out of the context window to
|
|
49
|
+
free up room. This is the record of it - treat it as your own memory of the
|
|
50
|
+
work so far, not as something the user told you.
|
|
51
|
+
|
|
52
|
+
{summary}
|
|
53
|
+
</summary>"""
|
|
54
|
+
|
|
55
|
+
def needed(usage):
|
|
56
|
+
"""Has the last request grown past the point where we rebuild?"""
|
|
57
|
+
return usage["prompt_tokens"] > config.CONTEXT_WINDOW * config.COMPACT_AT
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
ROLES = {"user": "USER", "assistant": "ASSISTANT", "tool": "TOOL RESULT"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def render(messages):
|
|
64
|
+
"""Flatten the transcript into something the summariser can read."""
|
|
65
|
+
lines = []
|
|
66
|
+
for message in messages:
|
|
67
|
+
if message["role"] == "system":
|
|
68
|
+
continue
|
|
69
|
+
|
|
70
|
+
content = message.get("content") or ""
|
|
71
|
+
for call in message.get("tool_calls") or []:
|
|
72
|
+
function = call["function"]
|
|
73
|
+
content += f"\n[called {function['name']}: {function['arguments']}]"
|
|
74
|
+
|
|
75
|
+
lines.append(f"{ROLES.get(message['role'], message['role'])}: {content}")
|
|
76
|
+
return "\n\n".join(lines)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def summarize(messages):
|
|
80
|
+
"""One LLM call, no tools. Returns the handoff note."""
|
|
81
|
+
response = client.chat.completions.create(
|
|
82
|
+
model=config.MODEL,
|
|
83
|
+
messages=[
|
|
84
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
85
|
+
{"role": "user", "content": render(messages)},
|
|
86
|
+
],
|
|
87
|
+
)
|
|
88
|
+
return response.choices[0].message.content
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def safe_boundary(messages, start):
|
|
92
|
+
"""First index at or after `start` where cutting cannot orphan a tool call.
|
|
93
|
+
|
|
94
|
+
A tool result has to keep the assistant message that asked for it, so the
|
|
95
|
+
only safe cut points are the messages that open a fresh exchange.
|
|
96
|
+
"""
|
|
97
|
+
for index in range(max(start, 1), len(messages)):
|
|
98
|
+
previous = messages[index - 1]
|
|
99
|
+
if messages[index]["role"] == "tool" or previous.get("tool_calls"):
|
|
100
|
+
continue
|
|
101
|
+
return index
|
|
102
|
+
return len(messages)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def tail_start(messages, budget):
|
|
106
|
+
"""Walk back from the end, taking messages until the tail fills `budget`."""
|
|
107
|
+
total = 0
|
|
108
|
+
for index in range(len(messages) - 1, 0, -1):
|
|
109
|
+
total += estimate([messages[index]])
|
|
110
|
+
if total > budget:
|
|
111
|
+
return safe_boundary(messages, index)
|
|
112
|
+
return safe_boundary(messages, 1)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def compact(messages):
|
|
116
|
+
"""system + summary + a recent tail. The caller freezes what comes back."""
|
|
117
|
+
cut = tail_start(messages, config.CONTEXT_WINDOW * config.COMPACT_TO)
|
|
118
|
+
if cut <= 1:
|
|
119
|
+
return messages # nothing old enough to be worth summarising
|
|
120
|
+
|
|
121
|
+
summary = summarize(messages[1:cut])
|
|
122
|
+
kept = [
|
|
123
|
+
messages[0],
|
|
124
|
+
{"role": "user", "content": HANDOFF.format(summary=summary)},
|
|
125
|
+
*messages[cut:],
|
|
126
|
+
]
|
|
127
|
+
|
|
128
|
+
# Shrink the retained tail now, while we are already paying for a rebuilt
|
|
129
|
+
# prefix. Stripping is idempotent, so from here the frozen block is final
|
|
130
|
+
# and stays byte-identical - and cached - until the next compaction.
|
|
131
|
+
strip(kept)
|
|
132
|
+
return kept
|
nightfall/config.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Settings: real environment variables first, then ~/.agents/env."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
ENV_FILE = Path.home() / ".agents" / "env"
|
|
7
|
+
|
|
8
|
+
if ENV_FILE.exists():
|
|
9
|
+
for line in ENV_FILE.read_text().splitlines():
|
|
10
|
+
if "=" in line and not line.startswith("#"):
|
|
11
|
+
key, value = line.split("=", 1)
|
|
12
|
+
os.environ.setdefault(key.strip(), value.strip())
|
|
13
|
+
|
|
14
|
+
BASE_URL = os.environ["BASE_URL"]
|
|
15
|
+
API_KEY = os.environ["API_KEY"]
|
|
16
|
+
MODEL = os.environ.get("MODEL", "deepseek/deepseek-v4-flash")
|
|
17
|
+
|
|
18
|
+
# How much room the model has, and how we spend it.
|
|
19
|
+
CONTEXT_WINDOW = int(os.environ.get("CONTEXT_WINDOW", 128_000))
|
|
20
|
+
COMPACT_AT = 0.85 # compact once the prompt crosses this much of the window
|
|
21
|
+
COMPACT_TO = 0.35 # and cut back to this much, so it does not retrigger soon
|
nightfall/context.py
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"""Late injection: a small block appended just before we send.
|
|
2
|
+
|
|
3
|
+
It goes at the END of the message list so the stable prefix in front of it
|
|
4
|
+
stays cached.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import subprocess
|
|
9
|
+
from datetime import datetime
|
|
10
|
+
|
|
11
|
+
from .todos import todos_prompt
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
LABELS = {"M": "modified", "D": "deleted", "A": "added", "??": "new"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def git(command):
|
|
18
|
+
result = subprocess.run(
|
|
19
|
+
f"git {command}", shell=True, capture_output=True, text=True
|
|
20
|
+
)
|
|
21
|
+
return result.stdout
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def file_hash(path):
|
|
25
|
+
file = Path(path)
|
|
26
|
+
return hashlib.md5(file.read_bytes()).hexdigest() if file.is_file() else None
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def git_state():
|
|
30
|
+
"""path -> (status, content hash) for every file git sees as changed."""
|
|
31
|
+
state = {}
|
|
32
|
+
for line in git("status --porcelain").splitlines():
|
|
33
|
+
path = line[3:]
|
|
34
|
+
state[path] = (line[:2].strip(), file_hash(path))
|
|
35
|
+
return state
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
LAST_STATE = git_state()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def file_changes():
|
|
42
|
+
"""Files whose status or contents moved since the previous turn."""
|
|
43
|
+
global LAST_STATE
|
|
44
|
+
now = git_state()
|
|
45
|
+
changed = {p: v[0] for p, v in now.items() if LAST_STATE.get(p) != v}
|
|
46
|
+
LAST_STATE = now
|
|
47
|
+
return changed
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def changes_note():
|
|
51
|
+
changed = file_changes()
|
|
52
|
+
if not changed:
|
|
53
|
+
return ""
|
|
54
|
+
lines = [f"{LABELS.get(code, code)}: {path}" for path, code in changed.items()]
|
|
55
|
+
return (
|
|
56
|
+
"\n<system-reminder>\n"
|
|
57
|
+
"These files changed since your last turn. Read them again before "
|
|
58
|
+
"editing:\n" + "\n".join(lines) + "\n</system-reminder>"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def todos_note():
|
|
63
|
+
plan = todos_prompt()
|
|
64
|
+
return f"\n<todos>\n{plan}\n</todos>" if plan else ""
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def reminder():
|
|
68
|
+
"""The block we append to the messages on every turn."""
|
|
69
|
+
return {
|
|
70
|
+
"role": "user",
|
|
71
|
+
"content": (
|
|
72
|
+
"<env>\n"
|
|
73
|
+
f"time: {datetime.now():%Y-%m-%d %H:%M}\n"
|
|
74
|
+
f"git branch: {git('branch --show-current').strip() or '(detached)'}\n"
|
|
75
|
+
"</env>" + todos_note() + changes_note()
|
|
76
|
+
),
|
|
77
|
+
}
|
nightfall/history.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Keeping the transcript small enough to send.
|
|
2
|
+
|
|
3
|
+
Three mechanisms, cheapest first. Only the first two live here; the expensive
|
|
4
|
+
one is compact.py.
|
|
5
|
+
|
|
6
|
+
1. cap - a fresh tool result is trimmed and the full text parked in a temp
|
|
7
|
+
file the agent can page through. Free: the decision is made once,
|
|
8
|
+
when the result is created, so it never edits the prefix.
|
|
9
|
+
2. strip - once a turn is over, its tool results shrink to a stub. The edit
|
|
10
|
+
lands at the tail, right before the next user message, so the
|
|
11
|
+
cached prefix in front of it survives.
|
|
12
|
+
3. drop - a single request is still too big. Throw tool results away whole,
|
|
13
|
+
oldest first, until it fits.
|
|
14
|
+
|
|
15
|
+
Everything here refuses to touch the locked prefix - the frozen
|
|
16
|
+
system + summary + head that compaction leaves behind. That block has to stay
|
|
17
|
+
byte-identical to stay cached.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
import json
|
|
21
|
+
import tempfile
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from . import config
|
|
25
|
+
|
|
26
|
+
CAP = 10_000 # chars of a fresh tool result the agent sees inline
|
|
27
|
+
STUB = 300 # chars kept once the turn that produced it is over
|
|
28
|
+
|
|
29
|
+
TRIMMED = "[output trimmed:" # marker, so stripping twice is a no-op
|
|
30
|
+
SUMMARY = "<summary>" # marks the handoff note compaction leaves behind
|
|
31
|
+
SPILLS = [] # temp files belonging to the current turn
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ------------------------------------------------------------------- 1. cap
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def spill(text):
|
|
38
|
+
"""Park the full output on disk for the rest of this turn."""
|
|
39
|
+
handle = tempfile.NamedTemporaryFile(
|
|
40
|
+
mode="w", prefix="nightfall-", suffix=".txt", delete=False
|
|
41
|
+
)
|
|
42
|
+
handle.write(text)
|
|
43
|
+
handle.close()
|
|
44
|
+
SPILLS.append(Path(handle.name))
|
|
45
|
+
return handle.name
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def cap(text):
|
|
49
|
+
"""Trim a fresh tool result, leaving a pointer to the whole thing."""
|
|
50
|
+
if len(text) <= CAP:
|
|
51
|
+
return text
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
path = spill(text)
|
|
55
|
+
except OSError:
|
|
56
|
+
# No temp file (read-only /tmp, no space). Still better to trim and
|
|
57
|
+
# say so than to fail the tool call outright.
|
|
58
|
+
return text[:CAP] + f"\n\n{TRIMMED} {len(text) - CAP} chars cut and the rest could not be saved.]"
|
|
59
|
+
return (
|
|
60
|
+
text[:CAP] + f"\n\n{TRIMMED} {len(text) - CAP} of {len(text)} chars cut. "
|
|
61
|
+
f"The whole output is at {path} - page through it with "
|
|
62
|
+
"head, tail, sed -n or grep. It is deleted when this turn ends.]"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def sweep():
|
|
67
|
+
"""Delete this turn's temp files. Their paths die with the tool results."""
|
|
68
|
+
for path in SPILLS:
|
|
69
|
+
path.unlink(missing_ok=True)
|
|
70
|
+
SPILLS.clear()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def locked(messages):
|
|
74
|
+
"""Length of the frozen prefix - everything up to and including the newest
|
|
75
|
+
summary. Derived rather than remembered, so it stays correct across
|
|
76
|
+
/compact, /rewind and switching sessions."""
|
|
77
|
+
for index in range(len(messages) - 1, -1, -1):
|
|
78
|
+
if SUMMARY in (messages[index].get("content") or ""):
|
|
79
|
+
return index + 1
|
|
80
|
+
return 0
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ----------------------------------------------------------------- 2. strip
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def strip(messages):
|
|
87
|
+
"""Shrink every tool result that is no longer part of the live turn.
|
|
88
|
+
|
|
89
|
+
Called once a turn has finished, so by now "everything unlocked" and
|
|
90
|
+
"everything the model no longer needs in full" are the same set.
|
|
91
|
+
"""
|
|
92
|
+
shrunk = 0
|
|
93
|
+
for message in messages[locked(messages):]:
|
|
94
|
+
content = message.get("content") or ""
|
|
95
|
+
if message["role"] != "tool" or TRIMMED in content or len(content) <= STUB:
|
|
96
|
+
continue
|
|
97
|
+
|
|
98
|
+
message["content"] = (
|
|
99
|
+
content[:STUB] + f"\n\n{TRIMMED} {len(content) - STUB} more chars. "
|
|
100
|
+
"Run the command again if you need them.]"
|
|
101
|
+
)
|
|
102
|
+
shrunk += 1
|
|
103
|
+
return shrunk
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ------------------------------------------------------------------ 3. drop
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def estimate(messages):
|
|
110
|
+
"""Rough token count. Good enough to decide whether to panic."""
|
|
111
|
+
return sum(len(json.dumps(m)) for m in messages) // 4
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def fit(messages):
|
|
115
|
+
"""Last resort: discard whole tool results, oldest first, until it fits.
|
|
116
|
+
|
|
117
|
+
Returns how many went. Normally zero - cap and strip do the real work.
|
|
118
|
+
"""
|
|
119
|
+
budget = config.CONTEXT_WINDOW * config.COMPACT_AT
|
|
120
|
+
dropped = 0
|
|
121
|
+
for message in messages[locked(messages):]:
|
|
122
|
+
if estimate(messages) <= budget:
|
|
123
|
+
break
|
|
124
|
+
if message["role"] == "tool" and TRIMMED not in (message.get("content") or ""):
|
|
125
|
+
message["content"] = f"{TRIMMED} dropped to fit the context window.]"
|
|
126
|
+
dropped += 1
|
|
127
|
+
return dropped
|
nightfall/llm.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
from openai import OpenAI
|
|
5
|
+
|
|
6
|
+
from . import config
|
|
7
|
+
from .skills import skills_prompt
|
|
8
|
+
from .tools import TOOLS, TOOL_SCHEMAS
|
|
9
|
+
|
|
10
|
+
client = OpenAI(
|
|
11
|
+
base_url=config.BASE_URL,
|
|
12
|
+
api_key=config.API_KEY,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
SYSTEM_PROMPT = f"""
|
|
16
|
+
You are a coding agent. Your job is to code. Always code.
|
|
17
|
+
Use the bash tool to inspect files.
|
|
18
|
+
Use write_file to create files and str_replace to edit them.
|
|
19
|
+
Answer back to the user once exploration is done.
|
|
20
|
+
|
|
21
|
+
For any task that takes more than one step, call write_todos first and plan it
|
|
22
|
+
out. Send the whole list every time you call it - it replaces the old one.
|
|
23
|
+
Keep exactly one task in_progress, mark it done the moment it is finished, and
|
|
24
|
+
move the next one to in_progress in the same call. Do not batch up completions
|
|
25
|
+
at the end. Skip the tool entirely for single-step tasks; it is noise there.
|
|
26
|
+
|
|
27
|
+
The current list is injected back to you every turn inside <todos> tags, so
|
|
28
|
+
that block - not the transcript - is the truth about where you are.
|
|
29
|
+
|
|
30
|
+
When you need to understand how something works - where a feature lives, how
|
|
31
|
+
data flows, what calls what - send a task subagent instead of grepping your
|
|
32
|
+
way there yourself. It explores in its own context window and hands you back
|
|
33
|
+
just the findings, so the search does not fill yours. It cannot see this
|
|
34
|
+
conversation, so write the question so it stands alone. Do all editing
|
|
35
|
+
yourself; the subagent only reads.
|
|
36
|
+
|
|
37
|
+
Long tool output is cut short, and the whole thing is written to a temp file
|
|
38
|
+
whose path is given at the cut. Page through it with head, tail, sed -n or
|
|
39
|
+
grep rather than asking for it again. That file only exists for the current
|
|
40
|
+
turn, so read it now or re-run the command later.
|
|
41
|
+
|
|
42
|
+
Your current working directory is: {os.getcwd()}
|
|
43
|
+
|
|
44
|
+
You have skills available. Each one is a set of instructions for a task.
|
|
45
|
+
If a skill matches what the user wants, call read_skill first and follow it.
|
|
46
|
+
|
|
47
|
+
{skills_prompt()}
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def call_llm(messages, tools=None):
|
|
52
|
+
response = client.chat.completions.create(
|
|
53
|
+
model=config.MODEL,
|
|
54
|
+
messages=messages,
|
|
55
|
+
tools=tools or TOOL_SCHEMAS,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
message = response.choices[0].message
|
|
59
|
+
|
|
60
|
+
completion_details = response.usage.completion_tokens_details
|
|
61
|
+
prompt_details = response.usage.prompt_tokens_details
|
|
62
|
+
|
|
63
|
+
usage = {
|
|
64
|
+
"prompt_tokens": response.usage.prompt_tokens,
|
|
65
|
+
"completion_tokens": response.usage.completion_tokens,
|
|
66
|
+
"reasoning_tokens": getattr(completion_details, "reasoning_tokens", None),
|
|
67
|
+
"cached_tokens": getattr(prompt_details, "cached_tokens", None),
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return message, usage
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
if __name__ == "__main__":
|
|
74
|
+
user_input = input("Enter your prompt> ")
|
|
75
|
+
|
|
76
|
+
message, usage = call_llm([
|
|
77
|
+
{"role": "system", "content": SYSTEM_PROMPT},
|
|
78
|
+
{"role": "user", "content": user_input},
|
|
79
|
+
])
|
|
80
|
+
|
|
81
|
+
print("\nAgent: ", message.content, "\n")
|
|
82
|
+
|
|
83
|
+
if message.tool_calls:
|
|
84
|
+
tool_call = message.tool_calls[0]
|
|
85
|
+
args = json.loads(tool_call.function.arguments)
|
|
86
|
+
result = TOOLS[tool_call.function.name](**args)
|
|
87
|
+
print("Tool: ", tool_call.function.name, args)
|
|
88
|
+
print(result, "\n")
|
|
89
|
+
|
|
90
|
+
print(usage)
|