mini-code-cli 0.0.8.3__py3-none-any.whl → 0.0.8.4__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.
- mini_code_cli/agent_loop.py +780 -274
- mini_code_cli/config.py +19 -0
- mini_code_cli/permissions.py +49 -0
- mini_code_cli/persistence.py +262 -0
- mini_code_cli/plan.py +116 -0
- mini_code_cli/prompts.py +36 -4
- mini_code_cli/runner.py +47 -0
- mini_code_cli/sandbox.py +53 -0
- mini_code_cli/subagents/__init__.py +51 -0
- mini_code_cli/subagents/base.py +283 -0
- mini_code_cli/subagents/builtin.py +79 -0
- mini_code_cli/tools.py +85 -20
- mini_code_cli/ui.py +325 -7
- mini_code_cli/utils.py +41 -85
- mini_code_cli-0.0.8.4.dist-info/METADATA +871 -0
- mini_code_cli-0.0.8.4.dist-info/RECORD +22 -0
- mini_code_cli-0.0.8.4.dist-info/licenses/LICENSE +661 -0
- mini_code_cli-0.0.8.3.dist-info/METADATA +0 -139
- mini_code_cli-0.0.8.3.dist-info/RECORD +0 -13
- mini_code_cli-0.0.8.3.dist-info/licenses/LICENSE +0 -9
- {mini_code_cli-0.0.8.3.dist-info → mini_code_cli-0.0.8.4.dist-info}/WHEEL +0 -0
- {mini_code_cli-0.0.8.3.dist-info → mini_code_cli-0.0.8.4.dist-info}/entry_points.txt +0 -0
- {mini_code_cli-0.0.8.3.dist-info → mini_code_cli-0.0.8.4.dist-info}/top_level.txt +0 -0
mini_code_cli/agent_loop.py
CHANGED
|
@@ -1,100 +1,94 @@
|
|
|
1
|
-
"""
|
|
1
|
+
"""mini_code_cli.agent_loop -- the interactive agent loop.
|
|
2
|
+
|
|
2
3
|
Usage:
|
|
3
4
|
mini-code
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
This module owns the top-level CLI: argument parsing, the welcome/help banners
|
|
7
|
+
and the :class:`AgentSession` that drives the read-input -> call-the-LLM -> run
|
|
8
|
+
tool-calls -> persist loop.
|
|
6
9
|
|
|
7
|
-
|
|
10
|
+
The pieces that had grown organically inside this file have been extracted into
|
|
11
|
+
smaller, focused modules so each concern is easy to read and test:
|
|
8
12
|
|
|
13
|
+
* :mod:`mini_code_cli.persistence` -- session histories / recorder / usage on disk.
|
|
14
|
+
* :mod:`mini_code_cli.sandbox` -- the ``--allowed-dir`` path sandbox.
|
|
15
|
+
* :mod:`mini_code_cli.permissions` -- pure "does this need a prompt?" helpers.
|
|
16
|
+
* :mod:`mini_code_cli.runner` -- running a tool with a wall-clock timeout.
|
|
17
|
+
* :mod:`mini_code_cli.config` -- shared default settings.
|
|
18
|
+
* :mod:`mini_code_cli.subagents` -- delegating a task to a sub-agent.
|
|
19
|
+
|
|
20
|
+
For backward compatibility (and the test-suite) the most commonly imported
|
|
21
|
+
helpers are re-exported from here, so ``from mini_code_cli.agent_loop import ...``
|
|
22
|
+
keeps working.
|
|
9
23
|
"""
|
|
10
24
|
|
|
11
25
|
import os
|
|
12
26
|
import json
|
|
13
|
-
import signal
|
|
14
|
-
|
|
15
|
-
import time
|
|
16
|
-
import uuid
|
|
17
|
-
|
|
18
27
|
import argparse
|
|
19
|
-
import sys
|
|
20
28
|
|
|
21
29
|
from . import prompts
|
|
22
30
|
from . import shell_safety_utils
|
|
23
31
|
from . import tools
|
|
24
32
|
from . import utils
|
|
25
33
|
from . import ui
|
|
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
|
-
with open(usage_path, 'w') as f:
|
|
85
|
-
json.dump(token_stats, f, indent=2)
|
|
86
|
-
return usage_path
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
def print_usage_summary(token_stats):
|
|
90
|
-
"""Print the accumulated token usage for the session so far."""
|
|
91
|
-
ui.console.usage_summary(token_stats)
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
def generate_unique_id():
|
|
95
|
-
"""Generate a unique ID based on current time and a random component."""
|
|
96
|
-
timestamp = int(time.time() * 1000000) # microseconds
|
|
97
|
-
return f"{timestamp}-{uuid.uuid4().hex[:8]}"
|
|
34
|
+
from . import subagents
|
|
35
|
+
from . import plan as plan_utils
|
|
36
|
+
from .config import (
|
|
37
|
+
DEFAULT_CACHE_LOCATION,
|
|
38
|
+
DEFAULT_MODEL_URL,
|
|
39
|
+
DEFAULT_TEMPERATURE,
|
|
40
|
+
DEFAULT_TOOL_TIMEOUT,
|
|
41
|
+
DEFAULT_HTTP_TIMEOUT,
|
|
42
|
+
DEFAULT_MAX_MESSAGES,
|
|
43
|
+
)
|
|
44
|
+
from .persistence import (
|
|
45
|
+
save_history,
|
|
46
|
+
save_summary,
|
|
47
|
+
save_usage,
|
|
48
|
+
load_history,
|
|
49
|
+
load_history_from_dir,
|
|
50
|
+
load_summary_from_dir,
|
|
51
|
+
load_usage_from_dir,
|
|
52
|
+
find_last_session_dir,
|
|
53
|
+
accumulate_usage,
|
|
54
|
+
generate_unique_id,
|
|
55
|
+
_restore_dir,
|
|
56
|
+
)
|
|
57
|
+
from .sandbox import is_within_allowed_dir, tool_paths_are_allowed, _FILE_TOOL_PATH_ARGS
|
|
58
|
+
from .permissions import (
|
|
59
|
+
needs_permission,
|
|
60
|
+
shell_forces_ask_permission,
|
|
61
|
+
sudo_disables_prompts,
|
|
62
|
+
)
|
|
63
|
+
from .runner import call_function_with_timeout
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
# re-exported helpers (kept for the test-suite / external callers)
|
|
68
|
+
"is_within_allowed_dir",
|
|
69
|
+
"tool_paths_are_allowed",
|
|
70
|
+
"_FILE_TOOL_PATH_ARGS",
|
|
71
|
+
"maybe_compact",
|
|
72
|
+
"accumulate_usage",
|
|
73
|
+
"shell_forces_ask_permission",
|
|
74
|
+
"sudo_disables_prompts",
|
|
75
|
+
"needs_permission",
|
|
76
|
+
"call_function_with_timeout",
|
|
77
|
+
"save_history",
|
|
78
|
+
"save_summary",
|
|
79
|
+
"load_history",
|
|
80
|
+
"generate_unique_id",
|
|
81
|
+
"find_last_session_dir",
|
|
82
|
+
# public classes / entry points
|
|
83
|
+
"AgentSession",
|
|
84
|
+
"main",
|
|
85
|
+
"parse_args",
|
|
86
|
+
]
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# argument parsing
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
98
92
|
|
|
99
93
|
def parse_args():
|
|
100
94
|
parser = argparse.ArgumentParser(description="Agent loop for interacting with a language model")
|
|
@@ -107,103 +101,89 @@ def parse_args():
|
|
|
107
101
|
|
|
108
102
|
# Other settings:
|
|
109
103
|
parser.add_argument("--cache-dir", type=str, help=f"The cache dir for the session histories. default={DEFAULT_CACHE_LOCATION}")
|
|
104
|
+
parser.add_argument("--restore-session", type=str, help="The location of a previous session to continue. (e.g. ~/.cache/mini_code/sessions/1792028090823027-a9d77653/)")
|
|
105
|
+
parser.add_argument("-l","--load-last-session", action="store_true", help="Load the most recent previous session from the cache dir and continue it (under a new session id), instead of starting fresh. --restore-session takes precedence if also given.")
|
|
110
106
|
|
|
111
107
|
# Agent behaviour related
|
|
112
108
|
parser.add_argument("--system-prompt", type=str, help="Replace system prompt, with a custom system prompt.")
|
|
113
|
-
parser.add_argument("--auto-mode", action="store_true", help="Whether to run the agent in `auto-mode'. Or default: `manual-mode'.")
|
|
109
|
+
parser.add_argument("-a", "--auto-mode", action="store_true", help="Whether to run the agent in `auto-mode'. Or default: `manual-mode'.")
|
|
114
110
|
parser.add_argument("--agent-md", type=str, help="If the agent should use an agent-md file... (it will added after system message.)")
|
|
115
111
|
parser.add_argument("--prompt", type=str, help="An initial prompt from the user...")
|
|
116
112
|
|
|
117
113
|
# Agent permissions related
|
|
118
114
|
parser.add_argument("--ask-permission", action="store_true", help="Ask for permission before any tool call.")
|
|
115
|
+
parser.add_argument("--sudo", action="store_true", help="DANGEROUS: never ask for permission. Every non-destructive tool call and shell command runs automatically without confirmation. Destructive (DENY-tier) commands are still refused.")
|
|
119
116
|
parser.add_argument("--allowed-dir", type=str, default=".", help="Allowed directory for file operations, default='./'")
|
|
120
|
-
parser.add_argument("--enable-shell", action="store_true", help="Allow shell execution. default= False")
|
|
117
|
+
parser.add_argument("-s", "--enable-shell", action="store_true", help="Allow shell execution. default= False")
|
|
118
|
+
parser.add_argument("-g","--enable-subagents", action="store_true", help="Allow the agent to delegate work to sub-agents (exposed as extra tools). default= False")
|
|
121
119
|
parser.add_argument("--llm-timeout", type=int, default=DEFAULT_HTTP_TIMEOUT, help=f"HTTP timeout in seconds for LLM requests (reasoning models can be slow). default={DEFAULT_HTTP_TIMEOUT}")
|
|
122
120
|
parser.add_argument("--tool-timeout", type=int, default=DEFAULT_TOOL_TIMEOUT, help=f"Timeout in seconds for a single tool call. default={DEFAULT_TOOL_TIMEOUT}")
|
|
121
|
+
parser.add_argument("-p","--plan", action="store_true", help="Enable plan mode: the agent keeps a CURRENT_PLAN.md (its step-by-step plan, marked finished when the goal is done) and CURRENT_GOAL.md (the user's latest instruction) in the session directory. Also changes how history compaction works.")
|
|
123
122
|
|
|
123
|
+
# Output verbosity
|
|
124
|
+
parser.add_argument("--verbose", action="store_true", help="Print the full, detailed output. Without it (default), only user/assistant turns, token usage and tool calls are shown.")
|
|
124
125
|
|
|
125
126
|
args = parser.parse_args()
|
|
126
127
|
return args
|
|
127
128
|
|
|
128
129
|
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# banners / helpers
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
def style_line_prompt(text):
|
|
135
|
+
"""A short, styled inline prompt (used for the 'You:' line)."""
|
|
136
|
+
return ui.console.style(text, bold=True, fg="cyan") + " "
|
|
137
|
+
|
|
138
|
+
|
|
129
139
|
def print_help(allowed_dir, server_url, args, unique_id, cache_dir, model_name):
|
|
130
140
|
ui.console.rule("help", fg="cyan")
|
|
131
141
|
if not args.auto_mode:
|
|
132
142
|
help_message = "Manual-mode: the agent asks for your input after each LLM prediction."
|
|
133
143
|
else:
|
|
134
144
|
help_message = "Auto-mode: the agent keeps calling the LLM until no further tool calls are possible."
|
|
135
|
-
ui.console.
|
|
145
|
+
ui.console.emit(ui.console.style(help_message, dim=True, fg="cyan"))
|
|
146
|
+
ui.console.emit(ui.console.style("History / navigation commands:", bold=True, fg="cyan"))
|
|
147
|
+
ui.console.emit(" /history list the conversation (turn number, what happened, where)")
|
|
148
|
+
ui.console.emit(" /history <n> show the full messages recorded for turn n")
|
|
149
|
+
ui.console.emit(" /reset [<n>] rewind to the end of turn n (drops everything after it)")
|
|
150
|
+
ui.console.emit(" /clear clear the screen (keeps the conversation history)")
|
|
151
|
+
ui.console.emit(" /help show this help")
|
|
152
|
+
ui.console.emit(" /quit, /exit save the session and exit")
|
|
153
|
+
if args.enable_subagents or args.verbose:
|
|
154
|
+
ui.console.emit(ui.console.style("Sub-agents:", bold=True, fg="cyan"))
|
|
155
|
+
for sa in subagents.get_registry().values():
|
|
156
|
+
ui.console.emit(f" {sa.name:16} {sa.description}")
|
|
136
157
|
ui.console.rule(char="═", fg="cyan")
|
|
137
158
|
|
|
138
|
-
def call_function_with_timeout(func, tool_params, name, timeout_seconds=DEFAULT_TOOL_TIMEOUT):
|
|
139
|
-
"""calls function with params, aborting after `timeout_seconds`."""
|
|
140
|
-
class TimeoutError(Exception):
|
|
141
|
-
pass
|
|
142
159
|
|
|
143
|
-
|
|
144
|
-
|
|
160
|
+
def print_welcome(allowed_dir, server_url, args, unique_id, cache_dir, model_name=None):
|
|
161
|
+
"""Render the startup info banner through the UI."""
|
|
162
|
+
version = __import__('mini_code_cli').__version__
|
|
145
163
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
signal.alarm(0) # Disable the alarm
|
|
155
|
-
|
|
156
|
-
return result
|
|
164
|
+
if getattr(args, "sudo", False):
|
|
165
|
+
permission_mode = "SUDO MODE: no permission prompts (DENY commands still blocked)."
|
|
166
|
+
elif args.ask_permission:
|
|
167
|
+
permission_mode = "All tool calls require permission."
|
|
168
|
+
else:
|
|
169
|
+
permission_mode = "Tool calls execute without asking."
|
|
170
|
+
agent_mode = "Agent is in manual-mode." if not args.auto_mode else "Agent is in auto-mode."
|
|
171
|
+
shell_mode = "Shell disabled." if not args.enable_shell else "Shell enabled."
|
|
157
172
|
|
|
173
|
+
ui.console.welcome(version, unique_id, cache_dir, allowed_dir,
|
|
174
|
+
server_url, model_name, permission_mode, agent_mode, shell_mode)
|
|
158
175
|
|
|
159
176
|
|
|
160
|
-
def
|
|
161
|
-
"
|
|
177
|
+
def _make_tool_call_note(console, count):
|
|
178
|
+
console.note(f"LLM is trying to call {count} tool(s).", "info")
|
|
162
179
|
|
|
163
|
-
Uses os.path.realpath to resolve symlinks and '..' so path-traversal or
|
|
164
|
-
symlink tricks cannot escape the allowed directory.
|
|
165
|
-
"""
|
|
166
|
-
target = os.path.realpath(file_path)
|
|
167
|
-
root = os.path.realpath(allowed_dir)
|
|
168
|
-
try:
|
|
169
|
-
return os.path.commonpath([target, root]) == root
|
|
170
|
-
except (ValueError, TypeError):
|
|
171
|
-
# Different drives (Windows) or a non-absolute path: fail closed.
|
|
172
|
-
return False
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
# Maps each filesystem tool to the names of its path-bearing arguments, so the
|
|
176
|
-
# --allowed-dir sandbox can be enforced uniformly. Keeping this in one place
|
|
177
|
-
# means a future tool cannot silently bypass the sandbox by using a different
|
|
178
|
-
# argument name (e.g. glob's `pattern` instead of `file_path`).
|
|
179
|
-
_FILE_TOOL_PATH_ARGS = {
|
|
180
|
-
"read_file": ["file_path"],
|
|
181
|
-
"write_file": ["file_path"],
|
|
182
|
-
"grep": ["file_path"],
|
|
183
|
-
"glob": ["pattern"],
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
def tool_paths_are_allowed(name, tool_params, allowed_dir):
|
|
188
|
-
"""Return True if every filesystem path argument of `name` stays inside
|
|
189
|
-
`allowed_dir`.
|
|
190
|
-
|
|
191
|
-
Tools not listed in _FILE_TOOL_PATH_ARGS have no path to check and pass
|
|
192
|
-
through (return True). A single path argument may itself be a list (e.g. a
|
|
193
|
-
batch of files), so each entry is validated.
|
|
194
|
-
"""
|
|
195
|
-
path_args = _FILE_TOOL_PATH_ARGS.get(name, [])
|
|
196
|
-
for arg_name in path_args:
|
|
197
|
-
value = tool_params.get(arg_name, "")
|
|
198
|
-
values = value if isinstance(value, (list, tuple)) else [value]
|
|
199
|
-
for path in values:
|
|
200
|
-
if path and not is_within_allowed_dir(path, allowed_dir):
|
|
201
|
-
return False
|
|
202
|
-
return True
|
|
203
180
|
|
|
181
|
+
# ---------------------------------------------------------------------------
|
|
182
|
+
# history compaction
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
204
184
|
|
|
205
|
-
def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats, max_messages=DEFAULT_MAX_MESSAGES):
|
|
206
|
-
"""Compact the conversation history once it grows beyond
|
|
185
|
+
def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats, history_index=0, max_messages=DEFAULT_MAX_MESSAGES):
|
|
186
|
+
"""Compact the conversation history once it grows beyond ``max_messages``.
|
|
207
187
|
|
|
208
188
|
Long (especially auto-mode) sessions can overflow the model's context window.
|
|
209
189
|
When the history gets too large we ask the LLM to summarize it and replace the
|
|
@@ -212,10 +192,14 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
212
192
|
cannot trigger further tool calls or recurse into compaction.
|
|
213
193
|
"""
|
|
214
194
|
if len(messages) <= max_messages:
|
|
215
|
-
|
|
195
|
+
# No compaction: keep the same segment (history_index) and signal the
|
|
196
|
+
# caller with (messages, did_compact=False, unchanged_index). Returning
|
|
197
|
+
# the full triple here is required -- callers unpack all three, so a
|
|
198
|
+
# bare messages would crash them.
|
|
199
|
+
return messages, False, history_index
|
|
216
200
|
|
|
217
201
|
try:
|
|
218
|
-
|
|
202
|
+
ui.console.note(f"Compacting conversation history ({len(messages)} messages -> summary)...", "warn")
|
|
219
203
|
summary_prompt = prompts.compaction_prompt(messages)
|
|
220
204
|
summary, _, compact_usage = utils.call_openai_server(
|
|
221
205
|
[{"role": "user", "content": summary_prompt}],
|
|
@@ -236,24 +220,21 @@ def maybe_compact(messages, server_url, model, api_key, llm_timeout, token_stats
|
|
|
236
220
|
"role": "user",
|
|
237
221
|
"content": f"Here is a summary of our earlier conversation so far:\n{summary}",
|
|
238
222
|
})
|
|
239
|
-
|
|
240
|
-
|
|
223
|
+
ui.console.note(f"History compacted to {len(compacted)} messages.", "warn")
|
|
224
|
+
# A new segment (history_index + 1) is started on compaction: the
|
|
225
|
+
# compacted messages are its snapshot, and every later turn appends
|
|
226
|
+
# to it. The caller records a single high-level 'summary' turn for
|
|
227
|
+
# this index so the recorder stays flat across the rollover.
|
|
228
|
+
return compacted, True, history_index + 1
|
|
241
229
|
except Exception as e:
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
return messages
|
|
245
|
-
|
|
230
|
+
ui.console.note(f"Failed to compact history: {e}", "error")
|
|
246
231
|
|
|
247
|
-
|
|
248
|
-
"""Whether enabling the shell should force per-command permission prompts.
|
|
232
|
+
return messages, False, history_index
|
|
249
233
|
|
|
250
|
-
Running shell commands without asking is dangerous (the agent can run any
|
|
251
|
-
command and escape --allowed-dir), so whenever the shell is enabled we
|
|
252
|
-
force ask-permission for the whole session. Exposed as a pure function so it
|
|
253
|
-
can be unit-tested independently of argparse / stdin.
|
|
254
|
-
"""
|
|
255
|
-
return bool(getattr(args, "enable_shell", False))
|
|
256
234
|
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
# the agent session
|
|
237
|
+
# ---------------------------------------------------------------------------
|
|
257
238
|
|
|
258
239
|
class AgentSession:
|
|
259
240
|
"""A single interactive agent session.
|
|
@@ -265,8 +246,9 @@ class AgentSession:
|
|
|
265
246
|
"""
|
|
266
247
|
|
|
267
248
|
EXIT_COMMANDS = {"quit", "exit", "/quit", "/exit"}
|
|
249
|
+
CLEAR_COMMANDS = {"clear", "/clear"}
|
|
268
250
|
|
|
269
|
-
def __init__(self, args):
|
|
251
|
+
def __init__(self, args, console=None):
|
|
270
252
|
self.args = args
|
|
271
253
|
self.allowed_dir = os.path.abspath(args.allowed_dir)
|
|
272
254
|
self.cache_dir = args.cache_dir if args.cache_dir else DEFAULT_CACHE_LOCATION
|
|
@@ -274,7 +256,10 @@ class AgentSession:
|
|
|
274
256
|
# The console that renders everything the agent prints. Unit tests /
|
|
275
257
|
# other callers can swap it out (e.g. AgentSession(args, console=...) with
|
|
276
258
|
# a color=False console) but it defaults to the shared ui.console.
|
|
277
|
-
self.console = ui.console
|
|
259
|
+
self.console = console if console is not None else ui.console
|
|
260
|
+
# --verbose toggles the amount of detail printed. Defaults to False so
|
|
261
|
+
# the quiet, transcript-only view is the out-of-the-box experience.
|
|
262
|
+
self.console.verbose = getattr(args, "verbose", False)
|
|
278
263
|
|
|
279
264
|
# LLM / server configuration.
|
|
280
265
|
self.server_url = f"{args.url}"
|
|
@@ -288,6 +273,18 @@ class AgentSession:
|
|
|
288
273
|
self.tools_dict = tools.util_get_tools_dict(forbidden_tools=forbidden)
|
|
289
274
|
self.llm_tools = tools.util_get_tools(forbidden_tools=forbidden)
|
|
290
275
|
|
|
276
|
+
# Sub-agents (opt-in via --enable-subagents). Each registered sub-agent
|
|
277
|
+
# becomes an extra tool the model can call; see subagents/. A child
|
|
278
|
+
# session built by a sub-agent never sets enable_subagents, so sub-agents
|
|
279
|
+
# cannot recursively spawn sub-agents.
|
|
280
|
+
self.enable_subagents = bool(getattr(args, "enable_subagents", False))
|
|
281
|
+
self.subagents_registry = subagents.get_registry() if self.enable_subagents else {}
|
|
282
|
+
for sa in self.subagents_registry.values():
|
|
283
|
+
self.llm_tools.append(sa.tool_schema())
|
|
284
|
+
# Marked so _run_one_tool_call knows to delegate rather than call a
|
|
285
|
+
# real function.
|
|
286
|
+
self.tools_dict[sa.name] = {"function": None, "description": sa.description, "subagent": True}
|
|
287
|
+
|
|
291
288
|
# Token accounting for the whole session.
|
|
292
289
|
self.token_stats = {
|
|
293
290
|
"prompt_tokens": 0,
|
|
@@ -299,15 +296,74 @@ class AgentSession:
|
|
|
299
296
|
|
|
300
297
|
self.unique_id = generate_unique_id()
|
|
301
298
|
|
|
302
|
-
#
|
|
303
|
-
|
|
299
|
+
# Plan-mode tracking (see plan.py). Always instantiated so the loop can
|
|
300
|
+
# call it unconditionally; every method no-ops when --plan is disabled.
|
|
301
|
+
self.plan_mode = bool(getattr(self.args, "plan", False))
|
|
302
|
+
self.plan_manager = plan_utils.PlanManager(
|
|
303
|
+
os.path.join(self.cache_dir, self.unique_id), enabled=self.plan_mode)
|
|
304
|
+
|
|
305
|
+
# High-level recorder + history segments. Instead of a single ever-growing
|
|
306
|
+
# history.json, a session keeps:
|
|
307
|
+
# * ``history_{n}.json`` files -- one per conversation *segment*; a brand
|
|
308
|
+
# new segment is started (n -> n+1) every time the history is compacted.
|
|
309
|
+
# * ``summary_history.json`` -- a flat, high-level *recorder*: one record
|
|
310
|
+
# per turn (the running turn number, *what* happened -- an assistant
|
|
311
|
+
# reply, a tool call, or a compaction summary -- and which
|
|
312
|
+
# ``history_{n}.json`` + message index the turn lives in).
|
|
313
|
+
# The recorder lets the user inspect and rewind the conversation (/history,
|
|
314
|
+
# /reset) without reading the full detailed histories, because every turn's
|
|
315
|
+
# recorded index marks a faithful end-of-turn snapshot of its segment.
|
|
316
|
+
self.history_index = 0
|
|
317
|
+
self.summary = []
|
|
318
|
+
|
|
319
|
+
# report_path is set on a *sub-agent child* session (by
|
|
320
|
+
# SubAgent._build_child_args) so it can exempt its own report.md -- which
|
|
321
|
+
# lives under the parent's cache dir, usually outside --allowed-dir --
|
|
322
|
+
# from the sandbox. A normal session leaves this None.
|
|
323
|
+
self.report_path = getattr(args, "report_path", None)
|
|
324
|
+
|
|
325
|
+
# Conversation state. When --restore-session is given we continue that
|
|
326
|
+
# session's history instead of starting fresh -- still under the brand
|
|
327
|
+
# new unique id above, so the original session file is never
|
|
328
|
+
# overwritten. Falls back to a freshly built history if the restore
|
|
329
|
+
# file is missing or unreadable.
|
|
330
|
+
# --load-last-session auto-detects the most recent previous session in the
|
|
331
|
+
# cache dir and continues it -- exactly like --restore-session, but without
|
|
332
|
+
# needing to know the folder name. An explicit --restore-session always wins
|
|
333
|
+
# and is used verbatim; otherwise the newest session is resolved here.
|
|
334
|
+
restore_session = getattr(self.args, "restore_session", None)
|
|
335
|
+
self.loaded_last_session_dir = None
|
|
336
|
+
if getattr(self.args, "load_last_session", False) and not restore_session:
|
|
337
|
+
detected = find_last_session_dir(self.cache_dir)
|
|
338
|
+
if detected:
|
|
339
|
+
restore_session = detected
|
|
340
|
+
self.loaded_last_session_dir = detected
|
|
341
|
+
restore_directory = _restore_dir(restore_session)
|
|
342
|
+
restored = load_history_from_dir(restore_directory) if restore_directory else None
|
|
343
|
+
self.messages = restored if restored is not None else self._build_initial_messages()
|
|
344
|
+
if restored is not None:
|
|
345
|
+
self._restore_usage_if_available(restore_session)
|
|
346
|
+
self._load_summary_if_restored(restore_directory)
|
|
347
|
+
else:
|
|
348
|
+
# --load-last-session asked for a session but nothing usable was found:
|
|
349
|
+
# don't advertise a load. The recorder starts empty and the first
|
|
350
|
+
# assistant / tool-call turn seeds turn 1 -- mirroring the restore
|
|
351
|
+
# path, which also has no leading system turn, so /history turn
|
|
352
|
+
# numbers stay consistent whether or not we restored.
|
|
353
|
+
self.loaded_last_session_dir = None
|
|
354
|
+
|
|
355
|
+
# Snapshot the initial (or restored) messages as the first history segment
|
|
356
|
+
# and persist the recorder, so a session starts with on-disk state to
|
|
357
|
+
# inspect and rewind to.
|
|
358
|
+
save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
|
|
359
|
+
save_summary(self.summary, self.cache_dir, self.unique_id)
|
|
304
360
|
|
|
305
361
|
def _build_initial_messages(self):
|
|
306
362
|
"""Assemble the system prompt and optional Agent.md into the first turns."""
|
|
307
363
|
if self.args.system_prompt:
|
|
308
364
|
system_prompt = self.args.system_prompt
|
|
309
365
|
else:
|
|
310
|
-
system_prompt = prompts.system_prompt(tools_dict=self.tools_dict, allowed_dir=self.allowed_dir)
|
|
366
|
+
system_prompt = prompts.system_prompt(tools_dict=self.tools_dict, allowed_dir=self.allowed_dir, plan_mode=self.plan_mode)
|
|
311
367
|
|
|
312
368
|
messages = [{"role": "system", "content": system_prompt}]
|
|
313
369
|
|
|
@@ -324,6 +380,144 @@ class AgentSession:
|
|
|
324
380
|
|
|
325
381
|
return messages
|
|
326
382
|
|
|
383
|
+
def _restore_usage_if_available(self, restore_session):
|
|
384
|
+
"""Seed this session's token totals from a sibling usage.json, if the
|
|
385
|
+
restored session had one, so the running summary stays continuous.
|
|
386
|
+
|
|
387
|
+
Best-effort only: token accounting is loaded on top of (never in place
|
|
388
|
+
of) the in-memory totals, and any problem here is ignored silently so a
|
|
389
|
+
missing or malformed usage file can never break a restore.
|
|
390
|
+
"""
|
|
391
|
+
if not restore_session:
|
|
392
|
+
return
|
|
393
|
+
directory = _restore_dir(restore_session)
|
|
394
|
+
saved = load_usage_from_dir(directory)
|
|
395
|
+
for key in self.token_stats:
|
|
396
|
+
if key in saved:
|
|
397
|
+
self.token_stats[key] += int(saved[key] or 0)
|
|
398
|
+
|
|
399
|
+
def _load_summary_if_restored(self, directory):
|
|
400
|
+
"""Restore the high-level recorder and segment index for a restore.
|
|
401
|
+
|
|
402
|
+
Prefers the saved ``summary_history.json`` so the recorder stays flat and
|
|
403
|
+
contiguous across the restore, seeding ``self.summary`` and the next
|
|
404
|
+
``history_index`` from its last record. If no recorder was saved (e.g. a
|
|
405
|
+
session created by older code, or only a classic ``history.json``), we
|
|
406
|
+
rebuild a minimal recorder by scanning the restored messages for
|
|
407
|
+
assistant turns, so navigation still works. Best-effort only.
|
|
408
|
+
"""
|
|
409
|
+
saved = load_summary_from_dir(directory)
|
|
410
|
+
saved = [r for r in saved if isinstance(r, dict)]
|
|
411
|
+
if saved:
|
|
412
|
+
self.summary = saved
|
|
413
|
+
self.history_index = int(saved[-1].get("history_file", 0))
|
|
414
|
+
return
|
|
415
|
+
turn = 0
|
|
416
|
+
for i, m in enumerate(self.messages):
|
|
417
|
+
if m.get("role") == "assistant":
|
|
418
|
+
turn += 1
|
|
419
|
+
self.summary.append({"turn": turn, "what": "assistant",
|
|
420
|
+
"history_file": 0, "index": i})
|
|
421
|
+
|
|
422
|
+
def _record_turn(self, what, messages, history_index=None):
|
|
423
|
+
"""Append one entry to the high-level recorder.
|
|
424
|
+
|
|
425
|
+
Each entry records the running turn number, a one-line *what* (assistant
|
|
426
|
+
reply, tool call(s), or a history compaction summary) and exactly where
|
|
427
|
+
the turn lives -- ``history_{history_index}.json`` at message ``index``.
|
|
428
|
+
``index`` points at the last message appended this step, i.e. the
|
|
429
|
+
end-of-turn snapshot that replaying ``messages`` reconstructs, which is
|
|
430
|
+
what lets :meth:`_reset_history` rewind to that exact state.
|
|
431
|
+
"""
|
|
432
|
+
if history_index is None:
|
|
433
|
+
history_index = self.history_index
|
|
434
|
+
self.summary.append({
|
|
435
|
+
"turn": len(self.summary) + 1,
|
|
436
|
+
"what": what,
|
|
437
|
+
"history_file": history_index,
|
|
438
|
+
"index": max(len(messages) - 1, 0),
|
|
439
|
+
})
|
|
440
|
+
|
|
441
|
+
def _show_history(self):
|
|
442
|
+
"""Print the high-level recorder as a numbered list of turns."""
|
|
443
|
+
rows = [(str(r.get("turn", i + 1)),
|
|
444
|
+
r.get("what", "?"),
|
|
445
|
+
f"history_{r.get('history_file', 0)}.json#{r.get('index', 0)}")
|
|
446
|
+
for i, r in enumerate(self.summary)]
|
|
447
|
+
self.console.history_list(rows)
|
|
448
|
+
|
|
449
|
+
def _inspect_history(self, arg):
|
|
450
|
+
"""Print the full messages recorded for a single turn."""
|
|
451
|
+
try:
|
|
452
|
+
record = self.summary[-1] if arg == "last" else self.summary[int(arg) - 1]
|
|
453
|
+
except (ValueError, IndexError):
|
|
454
|
+
self.console.note(f"No such turn {arg!r} (have 1..{len(self.summary)}).", "error")
|
|
455
|
+
return
|
|
456
|
+
messages = self._segment_upto(record)
|
|
457
|
+
if messages is None:
|
|
458
|
+
return
|
|
459
|
+
self.console.history_detail(record, messages, total=len(self.summary))
|
|
460
|
+
|
|
461
|
+
def _segment_upto(self, record):
|
|
462
|
+
"""Load a recorded turn's segment and truncate it at its index.
|
|
463
|
+
|
|
464
|
+
Returns the messages reproducing the conversation state at the end of
|
|
465
|
+
that turn, or None if the segment could not be loaded (never raises: a
|
|
466
|
+
missing or moved segment must not crash navigation).
|
|
467
|
+
"""
|
|
468
|
+
directory = os.path.join(self.cache_dir, self.unique_id)
|
|
469
|
+
segment_path = os.path.join(directory, f"history_{record.get('history_file', 0)}.json")
|
|
470
|
+
try:
|
|
471
|
+
with open(segment_path, "r", encoding="utf-8") as f:
|
|
472
|
+
segment = json.load(f)
|
|
473
|
+
except Exception:
|
|
474
|
+
self.console.note("Could not load the history segment for that turn.", "error")
|
|
475
|
+
return None
|
|
476
|
+
idx = max(0, min(int(record.get("index", 0)), len(segment) - 1))
|
|
477
|
+
return segment[:idx + 1]
|
|
478
|
+
|
|
479
|
+
def _reset_history(self, keep, record):
|
|
480
|
+
"""Rewind the conversation to the end of a recorded turn.
|
|
481
|
+
|
|
482
|
+
Rebuilds ``self.messages`` from the turn's recorded segment + index,
|
|
483
|
+
moves the session back onto that segment (``history_index``) and drops
|
|
484
|
+
everything after it -- both the extra messages and the later recorder
|
|
485
|
+
entries. New turns then continue from that state. Best-effort only: a
|
|
486
|
+
segment that cannot be loaded leaves the session unchanged.
|
|
487
|
+
"""
|
|
488
|
+
messages = self._segment_upto(record)
|
|
489
|
+
if messages is None:
|
|
490
|
+
return
|
|
491
|
+
self.messages = messages
|
|
492
|
+
self.history_index = int(record.get("history_file", 0))
|
|
493
|
+
self.summary = self.summary[:keep]
|
|
494
|
+
save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
|
|
495
|
+
save_summary(self.summary, self.cache_dir, self.unique_id)
|
|
496
|
+
self.console.note(
|
|
497
|
+
f"Reset to turn {keep}: kept {len(self.messages)} messages "
|
|
498
|
+
f"(history_{self.history_index}.json up to index "
|
|
499
|
+
f"{int(record.get('index', 0))}).", "ok")
|
|
500
|
+
self.console.note("Continuing: new turns will append to this state.", "info")
|
|
501
|
+
|
|
502
|
+
def _warn_sudo(self):
|
|
503
|
+
"""Print a prominent red warning about --sudo mode.
|
|
504
|
+
|
|
505
|
+
--sudo means the agent will never stop to ask before acting, so the user
|
|
506
|
+
is effectively handing over unattended control of their machine (within
|
|
507
|
+
the DENY-tier limits). We render it as a red box so it stands out on any
|
|
508
|
+
real terminal (it degrades to a plain ASCII box when colour is off).
|
|
509
|
+
"""
|
|
510
|
+
self.console.print_box(
|
|
511
|
+
[
|
|
512
|
+
"SUDO MODE: the agent will NEVER ask for permission.",
|
|
513
|
+
"All tool calls and shell commands run automatically and unattended.",
|
|
514
|
+
"Only clearly-destructive (DENY-tier) commands are still refused.",
|
|
515
|
+
"You are responsible for everything it does on this machine.",
|
|
516
|
+
],
|
|
517
|
+
title="DANGER",
|
|
518
|
+
fg="red",
|
|
519
|
+
)
|
|
520
|
+
|
|
327
521
|
def print_announcements(self):
|
|
328
522
|
"""Print the info / warning banners that depend on the CLI flags."""
|
|
329
523
|
if self.api_key:
|
|
@@ -334,12 +528,42 @@ class AgentSession:
|
|
|
334
528
|
if shell_forces_ask_permission(self.args):
|
|
335
529
|
self.console.announce_shell()
|
|
336
530
|
|
|
531
|
+
# --sudo hands the agent unattended control, so make that impossible to miss.
|
|
532
|
+
if sudo_disables_prompts(self.args):
|
|
533
|
+
self._warn_sudo()
|
|
534
|
+
|
|
535
|
+
if self.enable_subagents:
|
|
536
|
+
self.console.note(
|
|
537
|
+
f"Sub-agents enabled ({len(self.subagents_registry)} available: "
|
|
538
|
+
f"{', '.join(self.subagents_registry)}).", "info")
|
|
539
|
+
|
|
337
540
|
def run(self, initial_prompt=None):
|
|
338
541
|
"""Drive the agent loop until the user quits."""
|
|
339
542
|
self.print_announcements()
|
|
340
543
|
print_welcome(self.allowed_dir, self.server_url, self.args,
|
|
341
544
|
self.unique_id, self.cache_dir, self.model)
|
|
342
545
|
|
|
546
|
+
# Report the outcome of --load-last-session up front, so the user sees
|
|
547
|
+
# whether we resumed an earlier conversation (under a brand-new id) or
|
|
548
|
+
# started fresh because no previous session was found.
|
|
549
|
+
if self.loaded_last_session_dir is not None:
|
|
550
|
+
self.console.note(
|
|
551
|
+
f"Loaded most recent session "
|
|
552
|
+
f"({os.path.basename(self.loaded_last_session_dir)}) and continuing "
|
|
553
|
+
f"it under a new session id.", "ok")
|
|
554
|
+
elif getattr(self.args, "load_last_session", False):
|
|
555
|
+
self.console.note(
|
|
556
|
+
"No previous session found to load; starting a fresh session.", "warn")
|
|
557
|
+
|
|
558
|
+
# Hint at startup so the high-level history feature is discoverable:
|
|
559
|
+
# /history browses the recorder, /history <n> inspects a turn, and
|
|
560
|
+
# /reset <n> rewinds the conversation to that turn's state.
|
|
561
|
+
self.console.emit(
|
|
562
|
+
self.console.style(
|
|
563
|
+
"Tip: /history to browse the conversation, /history <n> to inspect a "
|
|
564
|
+
"turn, /reset <n> to rewind. Type /help for all commands.",
|
|
565
|
+
dim=True, fg="cyan"))
|
|
566
|
+
|
|
343
567
|
# The --prompt value is a one-shot initial turn: it is sent verbatim and
|
|
344
568
|
# then cleared, so later turns come from the user (manual mode) or are
|
|
345
569
|
# produced automatically by the loop (auto mode).
|
|
@@ -349,16 +573,27 @@ class AgentSession:
|
|
|
349
573
|
try:
|
|
350
574
|
# 1) Get the user's turn: the one-shot preset, or a fresh line.
|
|
351
575
|
if pending is not None:
|
|
352
|
-
|
|
353
|
-
|
|
576
|
+
self.console.rule(title="Preset prompt", char="=", fg="cyan")
|
|
577
|
+
self.console.turn("You", pending, accent="cyan")
|
|
354
578
|
user_input = pending
|
|
355
579
|
pending = None
|
|
356
580
|
else:
|
|
357
581
|
user_input = self._read_user_line()
|
|
358
582
|
if user_input is None: # quit / exit
|
|
359
|
-
self.
|
|
583
|
+
self._finish_goal_and_shutdown()
|
|
360
584
|
break
|
|
361
585
|
|
|
586
|
+
# Interactive slash commands are handled in place and never
|
|
587
|
+
# reach the LLM. _handle_command returns True when the input
|
|
588
|
+
# was such a command, so we just re-prompt (continue).
|
|
589
|
+
if user_input.startswith("/") and self._handle_command(user_input):
|
|
590
|
+
continue
|
|
591
|
+
|
|
592
|
+
# In plan mode, remember the user's latest instruction as the
|
|
593
|
+
# active goal so the loop (and plan-mode compaction) can track it
|
|
594
|
+
# even if the agent never writes it to disk itself.
|
|
595
|
+
self._capture_goal(user_input)
|
|
596
|
+
|
|
362
597
|
# 2) Hand the user turn to the model and act on its tool calls.
|
|
363
598
|
self.messages.append({"role": "user", "content": user_input})
|
|
364
599
|
while True:
|
|
@@ -376,58 +611,156 @@ class AgentSession:
|
|
|
376
611
|
# Ctrl-C mid-loop just saves and re-prompts rather than crashing.
|
|
377
612
|
self._persist()
|
|
378
613
|
|
|
379
|
-
|
|
380
|
-
"""Run a single LLM turn: call the model, render its text, run its tool
|
|
381
|
-
calls. Returns True if the LLM made tool calls and we're in auto-mode,
|
|
382
|
-
signalling the loop to continue without asking the user."""
|
|
383
|
-
# Keep long sessions within the model's context window.
|
|
384
|
-
self.messages = maybe_compact(
|
|
385
|
-
self.messages, self.server_url, self.model, self.api_key,
|
|
386
|
-
self.args.llm_timeout, self.token_stats,
|
|
387
|
-
)
|
|
388
|
-
|
|
389
|
-
response_text, tool_calls, usage = utils.call_openai_server(
|
|
390
|
-
self.messages,
|
|
391
|
-
max_tokens=self.args.max_tokens,
|
|
392
|
-
temperature=self.args.temperature,
|
|
393
|
-
model=self.model,
|
|
394
|
-
server_url=self.server_url,
|
|
395
|
-
tools=self.llm_tools,
|
|
396
|
-
api_key=self.api_key,
|
|
397
|
-
timeout=self.args.llm_timeout,
|
|
398
|
-
)
|
|
399
|
-
accumulate_usage(self.token_stats, usage)
|
|
400
|
-
self.console.emit(self.console.usage_inline(usage))
|
|
401
|
-
|
|
402
|
-
made_tool_calls = bool(tool_calls)
|
|
403
|
-
if made_tool_calls:
|
|
404
|
-
self.token_stats["tool_calls"] += len(tool_calls)
|
|
405
|
-
|
|
406
|
-
if response_text:
|
|
407
|
-
self._print_assistant(response_text)
|
|
614
|
+
# -- autonomous drive (used by sub-agents) ------------------------------
|
|
408
615
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
"role": "assistant",
|
|
412
|
-
"content": response_text,
|
|
413
|
-
"tool_calls": tool_calls,
|
|
414
|
-
})
|
|
415
|
-
self._run_tool_calls(tool_calls)
|
|
416
|
-
else:
|
|
417
|
-
self.messages.append({"role": "assistant", "content": response_text})
|
|
616
|
+
def _drive_auto(self, initial_task, max_steps=None):
|
|
617
|
+
"""Run unattended to completion and return the final assistant answer.
|
|
418
618
|
|
|
419
|
-
|
|
420
|
-
|
|
619
|
+
Appends ``initial_task`` as a user turn, then keeps stepping until the
|
|
620
|
+
model makes no further tool calls (auto-mode). ``max_steps`` caps the
|
|
621
|
+
number of model calls so a misbehaving sub-agent cannot loop forever.
|
|
622
|
+
"""
|
|
623
|
+
self.messages.append({"role": "user", "content": initial_task})
|
|
624
|
+
steps = 0
|
|
625
|
+
while True:
|
|
626
|
+
should_continue = self._step()
|
|
627
|
+
self._persist()
|
|
628
|
+
steps += 1
|
|
629
|
+
if max_steps and steps >= max_steps:
|
|
630
|
+
self.console.note(
|
|
631
|
+
f"Sub-agent reached max_steps ({max_steps}); stopping.", "warn")
|
|
632
|
+
break
|
|
633
|
+
if not should_continue:
|
|
634
|
+
break
|
|
635
|
+
return self._last_assistant_text()
|
|
636
|
+
|
|
637
|
+
def _last_assistant_text(self):
|
|
638
|
+
"""Return the content of the most recent assistant (non tool-call) turn."""
|
|
639
|
+
for m in reversed(self.messages):
|
|
640
|
+
if m.get("role") == "assistant" and not m.get("tool_calls"):
|
|
641
|
+
return m.get("content") or ""
|
|
642
|
+
return ""
|
|
643
|
+
|
|
644
|
+
# -- plan mode ---------------------------------------------------------
|
|
645
|
+
|
|
646
|
+
def _capture_goal(self, user_input):
|
|
647
|
+
"""Seed CURRENT_GOAL.md with the user's latest instruction (plan mode).
|
|
648
|
+
|
|
649
|
+
The loop owns the goal (the user's last instruction); the agent owns the
|
|
650
|
+
plan (CURRENT_PLAN.md). Overwriting on every turn keeps the goal current.
|
|
651
|
+
Slash commands and the empty input are not real instructions, so they are
|
|
652
|
+
ignored. No-op when --plan is off.
|
|
653
|
+
"""
|
|
654
|
+
if not self.plan_mode or user_input is None:
|
|
655
|
+
return
|
|
656
|
+
stripped = user_input.strip()
|
|
657
|
+
if not stripped or stripped.startswith("/"):
|
|
658
|
+
return
|
|
659
|
+
self.plan_manager.write_goal(user_input)
|
|
660
|
+
|
|
661
|
+
def _finish_goal_and_shutdown(self):
|
|
662
|
+
"""Mark the active goal finished in CURRENT_PLAN.md, then quit/save."""
|
|
663
|
+
self._mark_current_goal_finished()
|
|
664
|
+
self.shutdown()
|
|
665
|
+
|
|
666
|
+
def _mark_current_goal_finished(self):
|
|
667
|
+
"""Append a \"goal finished\" entry to the plan document (plan mode).
|
|
668
|
+
|
|
669
|
+
Uses the goal the loop last recorded (self.plan_manager.goal); a no-op
|
|
670
|
+
when --plan is off, so it is safe to call unconditionally.
|
|
671
|
+
"""
|
|
672
|
+
self.plan_manager.mark_finished(self.plan_manager.goal)
|
|
673
|
+
|
|
674
|
+
def _apply_plan_to_compacted(self, messages):
|
|
675
|
+
"""Re-inject the running plan + current goal after a history compaction.
|
|
676
|
+
|
|
677
|
+
Compaction replaces the transcript with a summary, which would otherwise
|
|
678
|
+
drop the agent's plan and the goal the loop seeded. We read them back off
|
|
679
|
+
disk and append them as fresh user turns so the model keeps working
|
|
680
|
+
against the goal it is still trying to finish. The goal is appended last
|
|
681
|
+
so it reads as the most recent instruction the model should act on.
|
|
682
|
+
"""
|
|
683
|
+
plan_text = self.plan_manager.read_plan()
|
|
684
|
+
goal_text = self.plan_manager.read_goal()
|
|
685
|
+
|
|
686
|
+
# Recover the user's latest instruction from the pre-compaction tail, in
|
|
687
|
+
# case the loop had not seeded a goal file yet (e.g. the agent finished
|
|
688
|
+
# everything before ever writing CURRENT_GOAL.md).
|
|
689
|
+
current_input = None
|
|
690
|
+
for m in reversed(messages):
|
|
691
|
+
if m.get("role") == "user":
|
|
692
|
+
current_input = m.get("content")
|
|
693
|
+
break
|
|
694
|
+
if current_input and not goal_text:
|
|
695
|
+
self.plan_manager.write_goal(current_input)
|
|
696
|
+
goal_text = self.plan_manager.read_goal()
|
|
697
|
+
|
|
698
|
+
rebuilt = list(messages)
|
|
699
|
+
if plan_text and plan_text.strip():
|
|
700
|
+
rebuilt.append({"role": "user",
|
|
701
|
+
"content": f"Here is the current plan for the active goal (from CURRENT_PLAN.md):\n{plan_text}"})
|
|
702
|
+
if goal_text and goal_text.strip():
|
|
703
|
+
rebuilt.append({"role": "user",
|
|
704
|
+
"content": f"The current goal / latest user instruction (from CURRENT_GOAL.md):\n{goal_text}"})
|
|
705
|
+
elif current_input:
|
|
706
|
+
rebuilt.append({"role": "user", "content": current_input})
|
|
707
|
+
return rebuilt
|
|
708
|
+
|
|
709
|
+
def _is_plan_file(self, name, tool_params):
|
|
710
|
+
"""Whether a read/write targets a plan-mode file in the session dir.
|
|
711
|
+
|
|
712
|
+
Lets the agent maintain CURRENT_PLAN.md / CURRENT_GOAL.md even though the
|
|
713
|
+
session directory is usually outside --allowed-dir. Scoped tightly: plan
|
|
714
|
+
mode only, only read_file / write_file, only when the resolved path is a
|
|
715
|
+
plan file living directly in this session's directory.
|
|
716
|
+
"""
|
|
717
|
+
if not self.plan_mode or name not in ("read_file", "write_file"):
|
|
718
|
+
return False
|
|
719
|
+
path = tool_params.get("file_path", "")
|
|
720
|
+
if not path:
|
|
721
|
+
return False
|
|
722
|
+
try:
|
|
723
|
+
resolved = os.path.realpath(path)
|
|
724
|
+
except (ValueError, TypeError):
|
|
725
|
+
return False
|
|
726
|
+
session = os.path.realpath(os.path.join(self.cache_dir, self.unique_id))
|
|
727
|
+
if os.path.dirname(resolved) != session:
|
|
728
|
+
return False
|
|
729
|
+
return os.path.basename(resolved) in (
|
|
730
|
+
plan_utils.PLAN_FILENAME, plan_utils.GOAL_FILENAME)
|
|
731
|
+
|
|
732
|
+
def _is_report_file(self, name, tool_params):
|
|
733
|
+
"""Whether a write targets this session's own sub-agent report.md.
|
|
734
|
+
|
|
735
|
+
A sub-agent writes its report.md under the parent's cache dir, which
|
|
736
|
+
normally sits outside --allowed-dir. This exempts that single, known file
|
|
737
|
+
so the sub-agent can actually write it (otherwise the sandbox would block
|
|
738
|
+
the write and the parent would never receive the report). Scoped tightly:
|
|
739
|
+
only when this is a child session (report_path set), only read_file /
|
|
740
|
+
write_file, and only when the resolved path is exactly the report file.
|
|
741
|
+
"""
|
|
742
|
+
if not self.report_path or name not in ("read_file", "write_file"):
|
|
743
|
+
return False
|
|
744
|
+
path = tool_params.get("file_path", "")
|
|
745
|
+
if not path:
|
|
746
|
+
return False
|
|
747
|
+
try:
|
|
748
|
+
resolved = os.path.realpath(path)
|
|
749
|
+
except (ValueError, TypeError):
|
|
750
|
+
return False
|
|
751
|
+
try:
|
|
752
|
+
expected = os.path.realpath(self.report_path)
|
|
753
|
+
except (ValueError, TypeError):
|
|
754
|
+
return False
|
|
755
|
+
return resolved == expected
|
|
421
756
|
|
|
422
|
-
|
|
757
|
+
# -- interactive input -------------------------------------------------
|
|
423
758
|
|
|
424
759
|
def _read_user_line(self):
|
|
425
760
|
"""Read one turn from the user. Returns the text to send, or None to quit."""
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
except KeyboardInterrupt:
|
|
430
|
-
print("\nThank you. Goodbye! Saving history...")
|
|
761
|
+
user_input = self.console.read_line(style_line_prompt("You:"))
|
|
762
|
+
if user_input is None: # EOF / Ctrl-C at the prompt
|
|
763
|
+
self.console.note("Thank you. Goodbye! Saving history...", "info")
|
|
431
764
|
return None
|
|
432
765
|
|
|
433
766
|
if user_input.lower() in self.EXIT_COMMANDS:
|
|
@@ -441,8 +774,71 @@ class AgentSession:
|
|
|
441
774
|
|
|
442
775
|
return user_input
|
|
443
776
|
|
|
777
|
+
def _handle_command(self, user_input):
|
|
778
|
+
"""Handle an interactive slash command before it reaches the LLM.
|
|
779
|
+
|
|
780
|
+
Supported:
|
|
781
|
+
/history list the high-level recorder (turn number, what
|
|
782
|
+
happened, and which history_{n}.json + index)
|
|
783
|
+
/history <n> show the full messages recorded for turn <n>
|
|
784
|
+
/reset [<n>] rewind the conversation to the end of turn <n>
|
|
785
|
+
(no <n> rewinds to the most recent turn)
|
|
786
|
+
/clear clear the screen (keeps the conversation)
|
|
787
|
+
/help show the command help
|
|
788
|
+
|
|
789
|
+
Returns True when the input was a recognised command and was fully
|
|
790
|
+
handled, so the caller re-prompts instead of sending it to the model.
|
|
791
|
+
Unknown slash commands print a note and are swallowed (also True).
|
|
792
|
+
"""
|
|
793
|
+
stripped = user_input.strip()
|
|
794
|
+
if not stripped.startswith("/"):
|
|
795
|
+
return False
|
|
796
|
+
parts = stripped[1:].split(None, 1)
|
|
797
|
+
cmd, arg = parts[0].lower(), parts[1] if len(parts) > 1 else ""
|
|
798
|
+
|
|
799
|
+
if cmd == "history":
|
|
800
|
+
self._inspect_history(arg) if arg else self._show_history()
|
|
801
|
+
return True
|
|
802
|
+
if cmd in ("reset", "rewind"):
|
|
803
|
+
self._do_reset(arg)
|
|
804
|
+
return True
|
|
805
|
+
if cmd == "help":
|
|
806
|
+
print_help(self.allowed_dir, self.server_url, self.args,
|
|
807
|
+
self.unique_id, self.cache_dir, self.model)
|
|
808
|
+
return True
|
|
809
|
+
if cmd in self.CLEAR_COMMANDS:
|
|
810
|
+
self.console.clear_screen()
|
|
811
|
+
return True
|
|
812
|
+
self.console.note(f"Unknown command {stripped!r}. Try /help.", "warn")
|
|
813
|
+
return True
|
|
814
|
+
|
|
815
|
+
def _do_reset(self, arg):
|
|
816
|
+
"""Rewind the conversation to the end of a recorded turn.
|
|
817
|
+
|
|
818
|
+
No argument resets to the most recent turn (a no-op that just replays the
|
|
819
|
+
current state). With <n>, rewinds to that turn, dropping the messages and
|
|
820
|
+
recorder entries after it.
|
|
821
|
+
"""
|
|
822
|
+
if not self.summary:
|
|
823
|
+
self.console.note("No history has been recorded yet.", "warn")
|
|
824
|
+
return
|
|
825
|
+
target = self.summary[-1]
|
|
826
|
+
if arg:
|
|
827
|
+
try:
|
|
828
|
+
idx = int(arg) - 1
|
|
829
|
+
if not (0 <= idx < len(self.summary)):
|
|
830
|
+
raise ValueError
|
|
831
|
+
target = self.summary[idx]
|
|
832
|
+
except ValueError:
|
|
833
|
+
self.console.note(
|
|
834
|
+
f"No such turn {arg!r} (have 1..{len(self.summary)}).", "error")
|
|
835
|
+
return
|
|
836
|
+
self._reset_history(target["turn"], target)
|
|
837
|
+
|
|
838
|
+
# -- tool execution ----------------------------------------------------
|
|
839
|
+
|
|
444
840
|
def _run_tool_calls(self, tool_calls):
|
|
445
|
-
self.console
|
|
841
|
+
_make_tool_call_note(self.console, len(tool_calls))
|
|
446
842
|
for call in tool_calls:
|
|
447
843
|
self._run_one_tool_call(call)
|
|
448
844
|
|
|
@@ -455,6 +851,15 @@ class AgentSession:
|
|
|
455
851
|
tool_params = json.loads(params_str)
|
|
456
852
|
except json.JSONDecodeError:
|
|
457
853
|
self.console.note(f"Error parsing arguments for tool {name}", "error")
|
|
854
|
+
self._append_tool_result(call_id,
|
|
855
|
+
f"Error: Unknown tool: {name}, params {params_str}")
|
|
856
|
+
return
|
|
857
|
+
|
|
858
|
+
# Sub-agent: delegate the task to a sub-agent (which spins up its own
|
|
859
|
+
# nested AgentSession) and feed the result back as the tool result.
|
|
860
|
+
if name in self.subagents_registry:
|
|
861
|
+
result = self._run_subagent(name, tool_params)
|
|
862
|
+
self._append_tool_result(call_id, result)
|
|
458
863
|
return
|
|
459
864
|
|
|
460
865
|
if name not in self.tools_dict:
|
|
@@ -463,13 +868,21 @@ class AgentSession:
|
|
|
463
868
|
f"Error: Unknown tool: {name}, params {params_str}")
|
|
464
869
|
return
|
|
465
870
|
|
|
466
|
-
# 1) Sandbox: keep every filesystem tool inside --allowed-dir
|
|
871
|
+
# 1) Sandbox: keep every filesystem tool inside --allowed-dir (plan mode
|
|
872
|
+
# additionally lets the agent maintain CURRENT_PLAN.md /
|
|
873
|
+
# CURRENT_GOAL.md in the session directory, which normally sits
|
|
874
|
+
# outside --allowed-dir, so those writes are exempted).
|
|
467
875
|
if not tool_paths_are_allowed(name, tool_params, self.allowed_dir):
|
|
468
|
-
self.
|
|
469
|
-
|
|
470
|
-
self.
|
|
471
|
-
|
|
472
|
-
|
|
876
|
+
if self._is_plan_file(name, tool_params):
|
|
877
|
+
pass # allow the plan/goal files into the session directory
|
|
878
|
+
elif self._is_report_file(name, tool_params):
|
|
879
|
+
pass # allow a sub-agent's own report.md under the cache dir
|
|
880
|
+
else:
|
|
881
|
+
self.console.note(
|
|
882
|
+
f"File operation not allowed outside allowed directory: {self.allowed_dir}", "error")
|
|
883
|
+
self._append_tool_result(call_id,
|
|
884
|
+
f"Error: File operation not allowed outside allowed directory: {self.allowed_dir}")
|
|
885
|
+
return
|
|
473
886
|
|
|
474
887
|
# 2) Shell-safety classifier (DENY / ASK / ALLOW), always enforced.
|
|
475
888
|
shell_cmd = None
|
|
@@ -487,97 +900,189 @@ class AgentSession:
|
|
|
487
900
|
# 3) Permission prompt. Either the user asked for prompts on every call,
|
|
488
901
|
# or the shell-safety classifier flagged this command as risky (ASK).
|
|
489
902
|
if needs_permission(self.args, shell_cmd, tier):
|
|
490
|
-
|
|
491
|
-
ok = self._ask_permission(name, params_str, shell_cmd, tier, reason)
|
|
492
|
-
else:
|
|
493
|
-
ok = self._ask_risky_command(shell_cmd, reason)
|
|
903
|
+
ok = self._ask_permission(name, params_str, shell_cmd, tier, reason)
|
|
494
904
|
if not ok:
|
|
495
905
|
self._append_tool_result(call_id,
|
|
496
906
|
f"User denied permission for tool {name} and params {params_str}.")
|
|
497
907
|
return
|
|
498
908
|
|
|
499
|
-
# 4) Execute the tool.
|
|
500
|
-
|
|
909
|
+
# 4) Execute the tool. In non-verbose mode the tool call (and the usage
|
|
910
|
+
# line above) lives in the transient region: it flashes while the
|
|
911
|
+
# agent works and is cleared in place, keeping the transcript down to
|
|
912
|
+
# just the turns. In verbose mode everything is printed permanently.
|
|
913
|
+
transient = not self.console.verbose
|
|
501
914
|
func = self.tools_dict[name]["function"]
|
|
502
915
|
result = call_function_with_timeout(func, tool_params, name, timeout_seconds=self.args.tool_timeout)
|
|
503
916
|
|
|
504
|
-
self.console.
|
|
917
|
+
self.console.tool_call_display(name, tool_params, result, transient=transient)
|
|
505
918
|
self._append_tool_result(call_id, str(result))
|
|
506
919
|
|
|
507
|
-
def
|
|
508
|
-
"""
|
|
509
|
-
self.
|
|
510
|
-
|
|
511
|
-
|
|
920
|
+
def _run_subagent(self, name, tool_params):
|
|
921
|
+
"""Run a sub-agent for ``task`` and return the text to feed back."""
|
|
922
|
+
subagent = self.subagents_registry[name]
|
|
923
|
+
task = tool_params.get("task", "")
|
|
924
|
+
max_steps = tool_params.get("max_steps")
|
|
925
|
+
self.console.note(f"Delegating to sub-agent '{name}' (task: {_oneline(task, 80)})", "info")
|
|
512
926
|
try:
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
927
|
+
result = subagent.run(task, parent=self, max_steps=max_steps)
|
|
928
|
+
self.console.note(f"Sub-agent '{name}' finished.", "ok")
|
|
929
|
+
return result
|
|
930
|
+
except Exception as e:
|
|
931
|
+
self.console.note(f"Sub-agent '{name}' failed: {e}", "error")
|
|
932
|
+
return f"Error: sub-agent '{name}' failed: {e}"
|
|
518
933
|
|
|
519
|
-
|
|
934
|
+
def _ask_permission(self, name, params_str, shell_cmd, tier, reason):
|
|
935
|
+
"""Prompt the user to confirm a tool call (ask-permission mode).
|
|
520
936
|
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
self.console.note(f"This shell command is flagged as risky: {shell_cmd!r} ({reason})", "warn")
|
|
937
|
+
Delegates the actual prompting -- a styled box plus the answer input --
|
|
938
|
+
to the console so all interactive input lives in one place. Returns True
|
|
939
|
+
to proceed."""
|
|
525
940
|
try:
|
|
526
|
-
|
|
527
|
-
except KeyboardInterrupt:
|
|
528
|
-
|
|
941
|
+
return self.console.ask_permission(name, params_str, shell_cmd, tier, reason)
|
|
942
|
+
except (EOFError, KeyboardInterrupt):
|
|
943
|
+
self.console.note("Thank you. Goodbye! Saving history...", "info")
|
|
529
944
|
self.shutdown()
|
|
530
945
|
raise _Abort
|
|
531
946
|
|
|
532
|
-
if permission_input.lower() not in ["y", "yes"]:
|
|
533
|
-
self.console.note("Command not run.", "warn")
|
|
534
|
-
self._append_tool_result(None, f"User declined to run risky command: {shell_cmd!r}.")
|
|
535
|
-
return False
|
|
536
|
-
return True
|
|
537
|
-
|
|
538
947
|
def _append_tool_result(self, call_id, content):
|
|
539
948
|
self.messages.append({"role": "tool", "tool_call_id": call_id, "content": str(content)})
|
|
540
949
|
|
|
541
950
|
def _print_assistant(self, text):
|
|
951
|
+
# A delimiter line before every agent turn keeps consecutive turns
|
|
952
|
+
# visually separated (the assistant may reply several times in a row
|
|
953
|
+
# in auto-mode, or right after a tool-call summary).
|
|
954
|
+
self.console.divider()
|
|
542
955
|
self.console.turn("Assistant", text, accent="green")
|
|
543
956
|
|
|
544
957
|
def _persist(self):
|
|
545
|
-
save_history(self.messages, self.
|
|
958
|
+
save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
|
|
959
|
+
save_summary(self.summary, self.cache_dir, self.unique_id)
|
|
546
960
|
usage_path = save_usage(self.token_stats, self.cache_dir, self.unique_id)
|
|
547
961
|
self.console.note(f"Saved usage to: {usage_path}", "info")
|
|
548
|
-
print_usage_summary(self.token_stats)
|
|
549
962
|
|
|
550
963
|
def shutdown(self):
|
|
551
964
|
"""Persist and print a final summary (called on quit / exit)."""
|
|
552
|
-
self.console.note(f"Saving history to: {self.cache_dir}/history_{self.unique_id}.json", "info")
|
|
553
965
|
self._persist()
|
|
966
|
+
self.console.note(
|
|
967
|
+
f"Saved session to {self.cache_dir}/{self.unique_id}/ "
|
|
968
|
+
f"(history_0..{self.history_index}.json + summary_history.json).", "ok")
|
|
969
|
+
print_usage_summary(self.token_stats)
|
|
554
970
|
|
|
971
|
+
# -- single LLM turn ---------------------------------------------------
|
|
555
972
|
|
|
556
|
-
|
|
557
|
-
|
|
973
|
+
def _step(self):
|
|
974
|
+
"""Run a single LLM turn: call the model, render its text, run its tool
|
|
975
|
+
calls. Returns True if the LLM made tool calls and we're in auto-mode,
|
|
976
|
+
signalling the loop to continue without asking the user."""
|
|
977
|
+
# Keep long sessions within the model's context window. Compaction
|
|
978
|
+
# rolls the session over to a fresh history segment (history_index + 1)
|
|
979
|
+
# and returns that bump so the recorder can stay flat across it.
|
|
980
|
+
self.messages, did_compact, new_index = maybe_compact(
|
|
981
|
+
self.messages, self.server_url, self.model, self.api_key,
|
|
982
|
+
self.args.llm_timeout, self.token_stats, self.history_index,
|
|
983
|
+
)
|
|
984
|
+
if did_compact:
|
|
985
|
+
self.history_index = new_index
|
|
986
|
+
# In plan mode, re-inject the running plan + current goal so a
|
|
987
|
+
# compaction (which would otherwise drop them) keeps the model
|
|
988
|
+
# working against the goal it is still trying to finish.
|
|
989
|
+
if self.plan_mode:
|
|
990
|
+
self.messages = self._apply_plan_to_compacted(self.messages)
|
|
991
|
+
# Snapshot the compacted messages into the new segment so its turns
|
|
992
|
+
# are preserved, and record the summary as the first turn of it.
|
|
993
|
+
save_history(self.messages, self.cache_dir, self.unique_id, self.history_index)
|
|
994
|
+
self._record_turn(
|
|
995
|
+
f"summary: history compacted to {len(self.messages)} messages",
|
|
996
|
+
self.messages)
|
|
997
|
+
|
|
998
|
+
# Show a "… thinking" spinner while the LLM works (in-place, non-scrolling;
|
|
999
|
+
# a no-op on non-TTY / ASCII output). Stopped right after the call returns.
|
|
1000
|
+
self.console.start_thinking()
|
|
1001
|
+
response_text, tool_calls, usage = utils.call_openai_server(
|
|
1002
|
+
self.messages,
|
|
1003
|
+
max_tokens=self.args.max_tokens,
|
|
1004
|
+
temperature=self.args.temperature,
|
|
1005
|
+
model=self.model,
|
|
1006
|
+
server_url=self.server_url,
|
|
1007
|
+
tools=self.llm_tools,
|
|
1008
|
+
api_key=self.api_key,
|
|
1009
|
+
timeout=self.args.llm_timeout,
|
|
1010
|
+
)
|
|
1011
|
+
self.console.stop_thinking()
|
|
1012
|
+
|
|
1013
|
+
# A failed LLM call returns (None, None, None): treat it as "nothing
|
|
1014
|
+
# happened this step" so the loop degrades gracefully (records a note
|
|
1015
|
+
# and re-prompts) instead of crashing.
|
|
1016
|
+
if usage is None:
|
|
1017
|
+
response_text = response_text or ""
|
|
1018
|
+
usage = {
|
|
1019
|
+
"prompt_tokens": 0,
|
|
1020
|
+
"completion_tokens": 0,
|
|
1021
|
+
"total_tokens": 0,
|
|
1022
|
+
"tool_calls": self.token_stats["tool_calls"],
|
|
1023
|
+
}
|
|
558
1024
|
|
|
1025
|
+
accumulate_usage(self.token_stats, usage)
|
|
1026
|
+
made_tool_calls = bool(tool_calls)
|
|
1027
|
+
if made_tool_calls:
|
|
1028
|
+
self.token_stats["tool_calls"] += len(tool_calls)
|
|
559
1029
|
|
|
560
|
-
|
|
561
|
-
|
|
1030
|
+
if response_text:
|
|
1031
|
+
self._print_assistant(response_text)
|
|
562
1032
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
1033
|
+
if tool_calls:
|
|
1034
|
+
self.messages.append({
|
|
1035
|
+
"role": "assistant",
|
|
1036
|
+
"content": response_text,
|
|
1037
|
+
"tool_calls": tool_calls,
|
|
1038
|
+
})
|
|
1039
|
+
self._run_tool_calls(tool_calls)
|
|
1040
|
+
else:
|
|
1041
|
+
self.messages.append({"role": "assistant", "content": response_text})
|
|
569
1042
|
|
|
1043
|
+
if not response_text and not made_tool_calls:
|
|
1044
|
+
self.console.note("Warning: no response was given by the LLM.", "error")
|
|
570
1045
|
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
1046
|
+
# Mirror the running tool-call total into this call's usage dict so the
|
|
1047
|
+
# per-call usage line reports the actual tool-call count (the server
|
|
1048
|
+
# does not, so usage.get("tool_calls") would always be 0 otherwise).
|
|
1049
|
+
usage["tool_calls"] = self.token_stats["tool_calls"]
|
|
1050
|
+
# Print the usage line LAST, transiently, so it always sits at the very
|
|
1051
|
+
# bottom of the screen. That way the next step can clear it in place
|
|
1052
|
+
# (cursor up + wipe + reprint) instead of jumping up past the assistant
|
|
1053
|
+
# turn above it -- which is what made the screen keep scrolling.
|
|
1054
|
+
self.console.usage_line(usage, transient=False)
|
|
1055
|
+
|
|
1056
|
+
# Append this step's turn to the high-level recorder. On compaction the
|
|
1057
|
+
# recorder was already seeded for the new segment above, so this becomes
|
|
1058
|
+
# that segment's next turn (assistant / tool call), pointing at the
|
|
1059
|
+
# freshly appended message -- the end-of-turn snapshot /reset targets.
|
|
1060
|
+
if tool_calls:
|
|
1061
|
+
self._record_turn(
|
|
1062
|
+
f"tool call(s): {', '.join(tc.get('function', {}).get('name', '?') for tc in tool_calls)}",
|
|
1063
|
+
self.messages)
|
|
1064
|
+
elif response_text:
|
|
1065
|
+
self._record_turn("assistant", self.messages)
|
|
574
1066
|
|
|
575
|
-
|
|
576
|
-
agent_mode = "Agent is in manual-mode." if not args.auto_mode else "Agent is in auto-mode."
|
|
577
|
-
shell_mode = "Shell disabled." if not args.enable_shell else "Shell enabled."
|
|
1067
|
+
return made_tool_calls and self.args.auto_mode
|
|
578
1068
|
|
|
579
|
-
|
|
580
|
-
|
|
1069
|
+
|
|
1070
|
+
class _Abort(Exception):
|
|
1071
|
+
"""Raised to unwind the permission prompt after a Ctrl-C / shutdown."""
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
# ---------------------------------------------------------------------------
|
|
1075
|
+
# module-level helpers kept for readability / backward compatibility
|
|
1076
|
+
# ---------------------------------------------------------------------------
|
|
1077
|
+
|
|
1078
|
+
def _oneline(text, limit):
|
|
1079
|
+
one = " ".join(line.strip() for line in str(text).splitlines())
|
|
1080
|
+
one = " ".join(one.split())
|
|
1081
|
+
return one[:limit] + "..." if len(one) > limit else one
|
|
1082
|
+
|
|
1083
|
+
|
|
1084
|
+
def print_usage_summary(token_stats):
|
|
1085
|
+
ui.console.usage_summary(token_stats)
|
|
581
1086
|
|
|
582
1087
|
|
|
583
1088
|
def main():
|
|
@@ -585,5 +1090,6 @@ def main():
|
|
|
585
1090
|
session = AgentSession(args)
|
|
586
1091
|
session.run(initial_prompt=args.prompt)
|
|
587
1092
|
|
|
1093
|
+
|
|
588
1094
|
if __name__ == "__main__":
|
|
589
1095
|
main()
|