scootcli 0.8.1__tar.gz → 0.9.0__tar.gz
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.
- {scootcli-0.8.1/src/scootcli.egg-info → scootcli-0.9.0}/PKG-INFO +5 -3
- {scootcli-0.8.1 → scootcli-0.9.0}/README.md +4 -2
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/__init__.py +1 -1
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/agent.py +82 -11
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/cli.py +2 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/worktree.py +9 -4
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/headless.py +2 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/hooks.py +16 -10
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/providers/anthropic.py +7 -3
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/providers/base.py +15 -1
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/providers/openai_chat.py +2 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/providers/openai_responses.py +7 -4
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/repl.py +30 -4
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/sessions.py +29 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/base.py +119 -16
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/edit_file.py +25 -12
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/write_file.py +11 -5
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/transport.py +27 -10
- scootcli-0.9.0/src/scootcli/worktree.py +163 -0
- {scootcli-0.8.1 → scootcli-0.9.0/src/scootcli.egg-info}/PKG-INFO +5 -3
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_agent.py +68 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_anthropic.py +23 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_hooks.py +24 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_sessions.py +33 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_streaming.py +53 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_tools.py +112 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_transport_native.py +54 -0
- scootcli-0.9.0/tests/test_worktree.py +197 -0
- scootcli-0.8.1/src/scootcli/worktree.py +0 -114
- scootcli-0.8.1/tests/test_worktree.py +0 -92
- {scootcli-0.8.1 → scootcli-0.9.0}/LICENSE +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/pyproject.toml +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/setup.cfg +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/__main__.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/activity.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/approvals.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/auth.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/clipboard.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/__init__.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/approve.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/auth.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/base.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/compact.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/copy.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/exit.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/forget.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/help.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/hooks.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/init.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/logo.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/model.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/panel.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/reset.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/resume.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/route.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/save.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/scope.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/sessions.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/status.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/verbosity.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/commands/yolo.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/config.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/context.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/credentials.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/errors.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/images.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/keys.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/lineeditor.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/logo.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/models.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/panel.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/preferences.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/presets.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/pricing.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/project.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/prompts.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/providers/__init__.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/providers/registry.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/providers/router.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/rendering.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/resize.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/status.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/__init__.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/list_dir.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/open_editor.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/read_file.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/run_shell.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/search.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/tools/update_plan.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/updates.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/vision.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli/workspace.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli.egg-info/SOURCES.txt +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli.egg-info/dependency_links.txt +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli.egg-info/entry_points.txt +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/src/scootcli.egg-info/top_level.txt +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_approvals.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_auth.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_clipboard.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_config.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_headless.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_images.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_lineeditor.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_logo.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_models.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_panel.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_preferences.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_providers.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_readiness.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_repl.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_resilience.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_resize.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_router.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_scope.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_small_060.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_smoke.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_vision.py +0 -0
- {scootcli-0.8.1 → scootcli-0.9.0}/tests/test_workspace.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: scootcli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.9.0
|
|
4
4
|
Summary: A tiny coding agent that goes where you point it
|
|
5
5
|
Author: Sergey Nes
|
|
6
6
|
License-Expression: MIT
|
|
@@ -239,7 +239,8 @@ When a call needs approval you can approve once `[a]`, trust that tool for the s
|
|
|
239
239
|
`/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
|
|
240
240
|
Catastrophic shell commands (a denylist: `rm -rf /`, `git push --force`, piping downloads into a shell, and so on) are re-confirmed in every mode.
|
|
241
241
|
|
|
242
|
-
For risky autonomous runs, `/worktree start` moves the work into a throwaway git worktree; `/worktree merge
|
|
242
|
+
For risky autonomous runs, `/worktree start` moves the work into a throwaway git worktree; `/worktree merge`, `/worktree keep`, or `/worktree discard` when done.
|
|
243
|
+
A commit or merge that fails keeps the worktree and your files; nothing is removed by force except by `discard`.
|
|
243
244
|
|
|
244
245
|
### Streaming, images, sessions
|
|
245
246
|
|
|
@@ -314,7 +315,8 @@ python -m pytest -q # network-free suite
|
|
|
314
315
|
./scripts/build.sh # dist/scoot.pyz + wheel + sdist
|
|
315
316
|
```
|
|
316
317
|
|
|
317
|
-
Design notes live in [`DESIGN.md`](./DESIGN.md), the behaviour spec in [`SPEC.md`](
|
|
318
|
+
Design notes live in [`DESIGN.md`](./DESIGN.md), the behaviour spec in [`SPEC.md`](SPEC.md), what is next in [`ROADMAP.md`](./ROADMAP.md), and the release history in [`CHANGELOG.md`](./CHANGELOG.md).
|
|
319
|
+
Want to build a CLI agent like this yourself? [`docs/blueprint.md`](./docs/blueprint.md) is the minimal spec: the components, the turn, the tools, the permission rules, and the acceptance checks, with no dependency on scoot's code.
|
|
318
320
|
New capability is a drop-in file: a tool in `tools/`, a slash command in `commands/`, a provider row or adapter in `providers/`.
|
|
319
321
|
|
|
320
322
|
## Security
|
|
@@ -214,7 +214,8 @@ When a call needs approval you can approve once `[a]`, trust that tool for the s
|
|
|
214
214
|
`/approve <mode>` sets how much runs without asking: `always` prompts for everything, `auto-read` auto-approves reads, `auto-edits` also auto-approves file edits, `yolo` runs everything.
|
|
215
215
|
Catastrophic shell commands (a denylist: `rm -rf /`, `git push --force`, piping downloads into a shell, and so on) are re-confirmed in every mode.
|
|
216
216
|
|
|
217
|
-
For risky autonomous runs, `/worktree start` moves the work into a throwaway git worktree; `/worktree merge
|
|
217
|
+
For risky autonomous runs, `/worktree start` moves the work into a throwaway git worktree; `/worktree merge`, `/worktree keep`, or `/worktree discard` when done.
|
|
218
|
+
A commit or merge that fails keeps the worktree and your files; nothing is removed by force except by `discard`.
|
|
218
219
|
|
|
219
220
|
### Streaming, images, sessions
|
|
220
221
|
|
|
@@ -289,7 +290,8 @@ python -m pytest -q # network-free suite
|
|
|
289
290
|
./scripts/build.sh # dist/scoot.pyz + wheel + sdist
|
|
290
291
|
```
|
|
291
292
|
|
|
292
|
-
Design notes live in [`DESIGN.md`](./DESIGN.md), the behaviour spec in [`SPEC.md`](
|
|
293
|
+
Design notes live in [`DESIGN.md`](./DESIGN.md), the behaviour spec in [`SPEC.md`](SPEC.md), what is next in [`ROADMAP.md`](./ROADMAP.md), and the release history in [`CHANGELOG.md`](./CHANGELOG.md).
|
|
294
|
+
Want to build a CLI agent like this yourself? [`docs/blueprint.md`](./docs/blueprint.md) is the minimal spec: the components, the turn, the tools, the permission rules, and the acceptance checks, with no dependency on scoot's code.
|
|
293
295
|
New capability is a drop-in file: a tool in `tools/`, a slash command in `commands/`, a provider row or adapter in `providers/`.
|
|
294
296
|
|
|
295
297
|
## Security
|
|
@@ -27,13 +27,20 @@ from .tools.base import ToolContext, ToolResult
|
|
|
27
27
|
|
|
28
28
|
@dataclass
|
|
29
29
|
class AgentOutcome:
|
|
30
|
-
status: str # "done" | "interrupted" | "aborted" | "max_steps" | "error"
|
|
30
|
+
status: str # "done" | "incomplete" | "interrupted" | "aborted" | "max_steps" | "error"
|
|
31
31
|
content: str = ""
|
|
32
32
|
error: str = ""
|
|
33
33
|
steps: int = 0
|
|
34
34
|
streamed: bool = False # True if the final content was already printed live (streaming)
|
|
35
35
|
|
|
36
36
|
|
|
37
|
+
# finish reasons that mean the reply is not the model's whole answer, and what to tell the user.
|
|
38
|
+
_CUT_OFF = {
|
|
39
|
+
"length": "the model hit its output limit",
|
|
40
|
+
"incomplete": "the stream ended before the reply was complete",
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
37
44
|
def _strip_done(text: str) -> str:
|
|
38
45
|
"""Remove a trailing ``DONE`` sentinel line from the model's final message."""
|
|
39
46
|
lines = (text or "").rstrip().splitlines()
|
|
@@ -104,6 +111,7 @@ class Agent:
|
|
|
104
111
|
steps = 0
|
|
105
112
|
compacted = False
|
|
106
113
|
stop_blocks = 0
|
|
114
|
+
cut_off = 0 # tool batches the model could not finish (output limit); bounded retries
|
|
107
115
|
while True:
|
|
108
116
|
if cancel_event.is_set():
|
|
109
117
|
return AgentOutcome("interrupted", steps=steps)
|
|
@@ -145,14 +153,28 @@ class Agent:
|
|
|
145
153
|
if result.tool_calls:
|
|
146
154
|
if (result.content or "").strip() and not streamed:
|
|
147
155
|
ui.assistant(result.content.strip())
|
|
156
|
+
if result.finish_reason in _CUT_OFF:
|
|
157
|
+
# The reply stopped before the calls were complete: their arguments cannot be
|
|
158
|
+
# trusted, so nothing runs. Tell the model once or twice, then give up.
|
|
159
|
+
cut_off += 1
|
|
160
|
+
self._close_batch(session, result.tool_calls,
|
|
161
|
+
"not executed: the reply was cut off before this tool call was "
|
|
162
|
+
"complete; call it again with less in one call")
|
|
163
|
+
if cut_off >= 2:
|
|
164
|
+
return AgentOutcome("incomplete", steps=steps, error=_CUT_OFF[result.finish_reason])
|
|
165
|
+
ui.assistant(f"reply cut off ({_CUT_OFF[result.finish_reason]}); asking the model to retry.")
|
|
166
|
+
continue
|
|
148
167
|
signal = self._run_tools(session, result.tool_calls, ui, cancel_event, cfg)
|
|
149
168
|
if signal in ("abort", "interrupted"):
|
|
150
169
|
status = "aborted" if signal == "abort" else "interrupted"
|
|
151
170
|
return AgentOutcome(status, steps=steps)
|
|
152
171
|
continue
|
|
153
172
|
|
|
154
|
-
# No tool calls -> the model is done, unless a Stop hook asks for more (bounded).
|
|
173
|
+
# No tool calls -> the model is done, unless it was cut off or a Stop hook asks for more (bounded).
|
|
155
174
|
content = _strip_done(result.content)
|
|
175
|
+
if result.finish_reason in _CUT_OFF:
|
|
176
|
+
return AgentOutcome("incomplete", content=content, steps=steps, streamed=streamed,
|
|
177
|
+
error=_CUT_OFF[result.finish_reason])
|
|
156
178
|
if stop_blocks < 3:
|
|
157
179
|
reason = self._stop_hook(session, content, steps)
|
|
158
180
|
if reason:
|
|
@@ -344,16 +366,48 @@ class Agent:
|
|
|
344
366
|
)
|
|
345
367
|
|
|
346
368
|
@staticmethod
|
|
347
|
-
def
|
|
369
|
+
def _tool_payload(result: ToolResult) -> str:
|
|
370
|
+
"""What the model reads back. On failure the error line comes first, followed by whatever
|
|
371
|
+
the tool captured: a failing test's or compiler's output is exactly what the model needs
|
|
372
|
+
next, and ``ERROR: exit 1`` alone only invites a blind rerun."""
|
|
373
|
+
if result.ok:
|
|
374
|
+
return result.content or "(no output)"
|
|
375
|
+
head = f"ERROR: {result.error or 'failed'}"
|
|
376
|
+
body = (result.content or "").strip()
|
|
377
|
+
if body.startswith(result.error or "\0"):
|
|
378
|
+
body = body[len(result.error):].lstrip("\n")
|
|
379
|
+
return f"{head}\n{body}" if body else head
|
|
380
|
+
|
|
381
|
+
@staticmethod
|
|
382
|
+
def _parse_args(raw) -> Optional[dict]:
|
|
383
|
+
"""The call's arguments as a dict; ``None`` when they are not a JSON object (a truncated or
|
|
384
|
+
malformed call must not run as if it had no arguments)."""
|
|
348
385
|
if isinstance(raw, dict):
|
|
349
386
|
return raw
|
|
350
387
|
if not raw:
|
|
351
388
|
return {}
|
|
352
389
|
try:
|
|
353
390
|
parsed = json.loads(raw)
|
|
354
|
-
return parsed if isinstance(parsed, dict) else {}
|
|
355
391
|
except (json.JSONDecodeError, TypeError):
|
|
356
|
-
return
|
|
392
|
+
return None
|
|
393
|
+
return parsed if isinstance(parsed, dict) else None
|
|
394
|
+
|
|
395
|
+
@staticmethod
|
|
396
|
+
def _close_batch(session, tool_calls, note: str) -> None:
|
|
397
|
+
"""Give every call in the batch that has no result yet a result saying why.
|
|
398
|
+
|
|
399
|
+
Called on abort, interrupt, and cut-off replies: a history with an unanswered tool call is
|
|
400
|
+
rejected by providers on the next request, so the turn must never leave one behind.
|
|
401
|
+
"""
|
|
402
|
+
answered = set()
|
|
403
|
+
for m in reversed(session.messages):
|
|
404
|
+
if m.get("role") != "tool":
|
|
405
|
+
break
|
|
406
|
+
answered.add(m.get("tool_call_id"))
|
|
407
|
+
for tc in tool_calls:
|
|
408
|
+
tc_id = tc.get("id", "")
|
|
409
|
+
if tc_id not in answered:
|
|
410
|
+
session.messages.append({"role": "tool", "tool_call_id": tc_id, "content": note})
|
|
357
411
|
|
|
358
412
|
# ── tool execution ───────────────────────────────────────────────────────────
|
|
359
413
|
def _run_tools(self, session, tool_calls, ui, cancel_event, cfg=None) -> Optional[str]:
|
|
@@ -379,7 +433,7 @@ class Agent:
|
|
|
379
433
|
pass
|
|
380
434
|
for tc in tool_calls:
|
|
381
435
|
if cancel_event.is_set():
|
|
382
|
-
return
|
|
436
|
+
return self._interrupted(session, tool_calls)
|
|
383
437
|
tc_id = tc.get("id", "")
|
|
384
438
|
fn = tc.get("function", {}) or {}
|
|
385
439
|
name = fn.get("name", "")
|
|
@@ -389,6 +443,11 @@ class Agent:
|
|
|
389
443
|
if tool is None:
|
|
390
444
|
self._append_tool(session, tc_id, f"error: unknown tool '{name}'")
|
|
391
445
|
continue
|
|
446
|
+
if args is None:
|
|
447
|
+
self._append_tool(session, tc_id, "error: the arguments are not a JSON object (the call may "
|
|
448
|
+
"have been cut off); call the tool again")
|
|
449
|
+
ui.tool_result(name, ToolResult(ok=False, summary="malformed arguments"))
|
|
450
|
+
continue
|
|
392
451
|
|
|
393
452
|
# Paths outside the workspace: ask the user once (this path, its directory, or anywhere).
|
|
394
453
|
outside = self._outside_paths(tool, args, ctx)
|
|
@@ -396,13 +455,16 @@ class Agent:
|
|
|
396
455
|
verdict = self._ask_scope(session, tool, outside, ctx, ui)
|
|
397
456
|
if verdict == "abort":
|
|
398
457
|
self._append_tool(session, tc_id, "user aborted the operation")
|
|
399
|
-
return
|
|
458
|
+
return self._aborted(session, tool_calls)
|
|
400
459
|
if verdict == "deny":
|
|
401
460
|
self._append_tool(session, tc_id, f"user declined access outside the workspace: {outside[0]}")
|
|
402
461
|
ui.tool_result(name, ToolResult(ok=False, summary="outside the workspace: declined"))
|
|
403
462
|
continue
|
|
404
463
|
# A PreToolUse hook may deny (skip the tool), allow (skip the prompt), or ask (force it).
|
|
405
464
|
hook_action, hook_reason = self._pre_tool_hook(session, tool, args, cancel_event)
|
|
465
|
+
if cancel_event.is_set(): # ESC while the hook ran
|
|
466
|
+
self._append_tool(session, tc_id, "not executed: the user interrupted the turn")
|
|
467
|
+
return self._interrupted(session, tool_calls)
|
|
406
468
|
if hook_action == "deny":
|
|
407
469
|
self._append_tool(session, tc_id, f"user declined via hook: {hook_reason or 'no reason given'}")
|
|
408
470
|
ui.tool_result(name, ToolResult(ok=False, summary=f"denied by hook: {hook_reason}"[:80]))
|
|
@@ -423,7 +485,7 @@ class Agent:
|
|
|
423
485
|
approval = ui.approve(tool, args, ctx)
|
|
424
486
|
if approval.decision == Decision.ABORT:
|
|
425
487
|
self._append_tool(session, tc_id, "user aborted the operation")
|
|
426
|
-
return
|
|
488
|
+
return self._aborted(session, tool_calls)
|
|
427
489
|
if approval.decision == Decision.SKIP:
|
|
428
490
|
self._append_tool(
|
|
429
491
|
session, tc_id, "user declined this action; consider another approach"
|
|
@@ -442,16 +504,25 @@ class Agent:
|
|
|
442
504
|
with ui.activity(f"running {name}…", cancel_event):
|
|
443
505
|
result = tool.run(args, ctx)
|
|
444
506
|
except Interrupted:
|
|
445
|
-
|
|
507
|
+
self._append_tool(session, tc_id, "interrupted by the user while running; it may have "
|
|
508
|
+
"partially completed, check before repeating it")
|
|
509
|
+
return self._interrupted(session, tool_calls)
|
|
446
510
|
except Exception as exc: # a tool must never take down the loop
|
|
447
511
|
result = ToolResult.fail(f"tool crashed: {exc}")
|
|
448
512
|
|
|
449
513
|
self._handle_result(session, name, result, ui)
|
|
450
514
|
self._post_tool_hook(session, tool, args, result)
|
|
451
|
-
|
|
452
|
-
self._append_tool(session, tc_id, payload or "(no output)")
|
|
515
|
+
self._append_tool(session, tc_id, self._tool_payload(result))
|
|
453
516
|
return None
|
|
454
517
|
|
|
518
|
+
def _aborted(self, session, tool_calls) -> str:
|
|
519
|
+
self._close_batch(session, tool_calls, "not executed: the user aborted the turn")
|
|
520
|
+
return "abort"
|
|
521
|
+
|
|
522
|
+
def _interrupted(self, session, tool_calls) -> str:
|
|
523
|
+
self._close_batch(session, tool_calls, "not executed: the user interrupted the turn")
|
|
524
|
+
return "interrupted"
|
|
525
|
+
|
|
455
526
|
@staticmethod
|
|
456
527
|
def _outside_paths(tool, args, ctx) -> "list":
|
|
457
528
|
from .tools.base import resolve_path
|
|
@@ -210,6 +210,8 @@ def _run_once(config: Config, pool: ProviderPool, prompt: str, as_json: bool, re
|
|
|
210
210
|
f"completion={u.get('completion_tokens', '?')} "
|
|
211
211
|
f"cost={fmt(session.session_cost())}", "gray"))
|
|
212
212
|
return 0
|
|
213
|
+
if outcome.status == "incomplete" and outcome.content.strip() and not outcome.streamed:
|
|
214
|
+
print(outcome.content.strip())
|
|
213
215
|
eprint(color(f"⚠ {redact(outcome.error or outcome.status)}", "red"))
|
|
214
216
|
return 1
|
|
215
217
|
|
|
@@ -25,7 +25,7 @@ def _start(session):
|
|
|
25
25
|
eprint(color(f"could not create worktree: {exc}", "red"))
|
|
26
26
|
return
|
|
27
27
|
session.worktree = w
|
|
28
|
-
session.
|
|
28
|
+
session.switch_root(w.path)
|
|
29
29
|
print(color(f"⚙ isolated in worktree {w.branch}", "cyan"))
|
|
30
30
|
print(color(f" root → {w.path}", "gray"))
|
|
31
31
|
print(color(" work freely (try /yolo); then /worktree merge|keep|discard", "gray"))
|
|
@@ -36,10 +36,15 @@ def _finish(session, action):
|
|
|
36
36
|
if w is None:
|
|
37
37
|
print(color("not in a worktree.", "yellow"))
|
|
38
38
|
return
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
result = wt.finish(w, action)
|
|
40
|
+
print(color(result.message, "gray" if result.ok else "yellow"))
|
|
41
|
+
if result.retained:
|
|
42
|
+
# The work is still in the worktree; the session stays there so nothing is lost.
|
|
43
|
+
print(color(f" still in worktree {w.branch} at {w.path}", "gray"))
|
|
44
|
+
print(color(f" fix it and run /worktree {action} again, or /worktree discard to drop it", "gray"))
|
|
45
|
+
return
|
|
46
|
+
session.switch_root(w.original_root)
|
|
41
47
|
session.worktree = None
|
|
42
|
-
print(color(f"{msg}", "gray"))
|
|
43
48
|
print(color(f" root → {w.original_root}", "gray"))
|
|
44
49
|
|
|
45
50
|
|
|
@@ -302,6 +302,8 @@ class Headless:
|
|
|
302
302
|
self.writer.emit("notice", message=f"reached the step limit ({self.session.config.max_steps}); send another prompt to continue")
|
|
303
303
|
if outcome.status == "error":
|
|
304
304
|
self.writer.emit("error", message=outcome.error, kind="turn")
|
|
305
|
+
if outcome.status == "incomplete":
|
|
306
|
+
self.writer.emit("notice", message=f"reply cut off: {outcome.error}; the content is partial")
|
|
305
307
|
self.writer.emit("turn_end", turn=self._turn, status=outcome.status, steps=outcome.steps,
|
|
306
308
|
model=self.session.active_model, usage=self.session.last_usage,
|
|
307
309
|
cost=self._turn_cost(before), cost_session=self._session_cost(),
|
|
@@ -24,7 +24,6 @@ from __future__ import annotations
|
|
|
24
24
|
import json
|
|
25
25
|
import os
|
|
26
26
|
import re
|
|
27
|
-
import subprocess
|
|
28
27
|
import threading
|
|
29
28
|
import time
|
|
30
29
|
from dataclasses import dataclass, field
|
|
@@ -186,29 +185,36 @@ class Hooks:
|
|
|
186
185
|
return Decision(context="\n".join(context))
|
|
187
186
|
|
|
188
187
|
def _run_command(self, event: str, command: str, payload: dict, timeout: int, cancel_event) -> Decision:
|
|
188
|
+
from .errors import Interrupted
|
|
189
|
+
from .tools.base import ToolError, run_subprocess
|
|
190
|
+
|
|
189
191
|
env = dict(os.environ)
|
|
190
192
|
env["SCOOT_SESSION_ID"] = str(payload.get("session_id", ""))
|
|
191
193
|
env["SCOOT_HOOK_EVENT"] = event
|
|
192
194
|
started = time.time()
|
|
193
195
|
try:
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
196
|
+
# The same runner as the tools: its own process group, bounded waits, ESC honoured.
|
|
197
|
+
returncode, stdout, stderr = run_subprocess(
|
|
198
|
+
["/bin/sh", "-c", command], self.root, cancel_event, timeout=timeout, env=env,
|
|
199
|
+
input_text=json.dumps(payload),
|
|
197
200
|
)
|
|
198
|
-
except
|
|
201
|
+
except ToolError:
|
|
199
202
|
self._record(event, command, "timeout", f"after {timeout}s", started)
|
|
200
203
|
return Decision()
|
|
204
|
+
except Interrupted:
|
|
205
|
+
self._record(event, command, "cancelled", "by the user", started)
|
|
206
|
+
return Decision()
|
|
201
207
|
except OSError as exc:
|
|
202
208
|
self._record(event, command, "error", str(exc), started)
|
|
203
209
|
return Decision()
|
|
204
|
-
out = (
|
|
205
|
-
err = (
|
|
206
|
-
if
|
|
210
|
+
out = (stdout or "")[:_MAX_CAPTURE].strip()
|
|
211
|
+
err = (stderr or "")[:_MAX_CAPTURE].strip()
|
|
212
|
+
if returncode == 2:
|
|
207
213
|
action = "deny" if event == "PreToolUse" else "block"
|
|
208
214
|
self._record(event, command, action, err, started)
|
|
209
215
|
return Decision(action=action, reason=err or "blocked by hook")
|
|
210
|
-
if
|
|
211
|
-
self._record(event, command, "error", f"exit {
|
|
216
|
+
if returncode != 0:
|
|
217
|
+
self._record(event, command, "error", f"exit {returncode}: {err[:200]}", started)
|
|
212
218
|
return Decision()
|
|
213
219
|
if not out:
|
|
214
220
|
self._record(event, command, "ok", "", started)
|
|
@@ -36,6 +36,7 @@ from .base import (
|
|
|
36
36
|
ChatRequest,
|
|
37
37
|
ChatResult,
|
|
38
38
|
decode_json,
|
|
39
|
+
STREAM_ENDED,
|
|
39
40
|
finish_stream,
|
|
40
41
|
iter_sse_json,
|
|
41
42
|
normalize_usage,
|
|
@@ -184,7 +185,7 @@ def translate_tool(tool: dict) -> dict:
|
|
|
184
185
|
|
|
185
186
|
# ── response parsing ────────────────────────────────────────────────────────────
|
|
186
187
|
_STOP = {"end_turn": "stop", "tool_use": "tool_calls", "max_tokens": "length", "stop_sequence": "stop",
|
|
187
|
-
"pause_turn": "stop", "refusal": "refusal"}
|
|
188
|
+
"pause_turn": "stop", "refusal": "refusal", STREAM_ENDED: "incomplete"}
|
|
188
189
|
|
|
189
190
|
|
|
190
191
|
def total_input_usage(usage: Optional[dict]) -> dict:
|
|
@@ -213,7 +214,9 @@ def build_result(blocks: List[dict], model: str, usage: Optional[dict], stop_rea
|
|
|
213
214
|
"function": {"name": b.get("name", ""), "arguments": json.dumps(b.get("input") or {})}}
|
|
214
215
|
for b in blocks if b.get("type") == "tool_use"]
|
|
215
216
|
content = streamed_text or "".join(texts)
|
|
216
|
-
finish =
|
|
217
|
+
finish = _STOP.get(stop_reason, "stop")
|
|
218
|
+
if finish not in ("length", "incomplete") and tool_calls:
|
|
219
|
+
finish = "tool_calls"
|
|
217
220
|
message: dict = {"role": "assistant", "content": content}
|
|
218
221
|
if tool_calls:
|
|
219
222
|
message["tool_calls"] = tool_calls
|
|
@@ -351,4 +354,5 @@ class AnthropicProvider(BaseProvider):
|
|
|
351
354
|
raise_for_status(400, json.dumps({"error": state.error}), self.name)
|
|
352
355
|
raise ApiError(str(state.error))
|
|
353
356
|
return build_result(state.ordered_blocks(), self.qualified(state.model or req.model), state.usage,
|
|
354
|
-
state.stop_reason, state.stop_details,
|
|
357
|
+
state.stop_reason or STREAM_ENDED, state.stop_details,
|
|
358
|
+
streamed_text="".join(state.text))
|
|
@@ -380,20 +380,34 @@ def iter_sse_json(lines: Iterator[str], status_holder: dict, leftovers: List[str
|
|
|
380
380
|
status_holder["saw_data"] = True
|
|
381
381
|
data = stripped[len("data:"):].strip()
|
|
382
382
|
if data == "[DONE]":
|
|
383
|
+
status_holder["done"] = True
|
|
383
384
|
continue
|
|
384
385
|
try:
|
|
385
386
|
yield json.loads(data)
|
|
386
387
|
except json.JSONDecodeError:
|
|
388
|
+
# Counted, not skipped: a dropped event means lost text or a broken tool call.
|
|
389
|
+
status_holder["malformed"] = status_holder.get("malformed", 0) + 1
|
|
387
390
|
continue
|
|
388
391
|
else:
|
|
389
392
|
leftovers.append(line)
|
|
390
393
|
|
|
391
394
|
|
|
392
395
|
def finish_stream(status_holder: dict, leftovers: List[str], provider: str) -> None:
|
|
393
|
-
"""After a stream ends: raise the typed error for a non-200 status or a plain error body
|
|
396
|
+
"""After a stream ends: raise the typed error for a non-200 status or a plain error body, for
|
|
397
|
+
a 200 that carried no events at all, and for events that could not be parsed."""
|
|
394
398
|
status = status_holder.get("status", 200)
|
|
395
399
|
if status != 200 or (not status_holder.get("saw_data") and leftovers):
|
|
396
400
|
body = "".join(leftovers).strip()
|
|
397
401
|
raise_for_status(status if status != 200 else 400, body, provider)
|
|
398
402
|
if body:
|
|
399
403
|
raise ApiError(body[:200], status=status)
|
|
404
|
+
if not status_holder.get("saw_data"):
|
|
405
|
+
raise ApiError(f"{provider} returned an empty stream (no events before the connection closed)",
|
|
406
|
+
status=status, hint="a proxy or the endpoint closed the connection early; try again")
|
|
407
|
+
malformed = status_holder.get("malformed", 0)
|
|
408
|
+
if malformed:
|
|
409
|
+
raise ApiError(f"{provider} sent {malformed} unreadable stream event(s); the reply is not complete",
|
|
410
|
+
status=status, hint="try again; if it repeats, check the proxy or the endpoint")
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
STREAM_ENDED = "stream_ended" # an adapter's stop marker when the stream closed before its terminal event
|
|
@@ -137,6 +137,8 @@ class OpenAIChatProvider(BaseProvider):
|
|
|
137
137
|
if text:
|
|
138
138
|
on_delta(text)
|
|
139
139
|
finish_stream(holder, leftovers, self.name)
|
|
140
|
+
if not acc.finish_reason and not holder.get("done"):
|
|
141
|
+
acc.finish_reason = "incomplete" # neither a finish_reason nor [DONE]: the stream broke off
|
|
140
142
|
result = acc.result(req.model)
|
|
141
143
|
result.model = self.qualified(result.model)
|
|
142
144
|
return result
|
|
@@ -26,6 +26,7 @@ from .base import (
|
|
|
26
26
|
ChatRequest,
|
|
27
27
|
ChatResult,
|
|
28
28
|
decode_json,
|
|
29
|
+
STREAM_ENDED,
|
|
29
30
|
finish_stream,
|
|
30
31
|
iter_sse_json,
|
|
31
32
|
normalize_usage,
|
|
@@ -149,10 +150,12 @@ def build_result(items: List[dict], model: str, usage: Optional[dict], status: s
|
|
|
149
150
|
texts = [_text_of(it) for it in items if it.get("type") == "message"]
|
|
150
151
|
tool_calls = [_tool_call_of(it) for it in items if it.get("type") == "function_call"]
|
|
151
152
|
content = streamed_text or "".join(texts)
|
|
152
|
-
if
|
|
153
|
+
if status == "incomplete":
|
|
154
|
+
finish = "length" # even with tool calls: their arguments may be cut short
|
|
155
|
+
elif status == STREAM_ENDED:
|
|
156
|
+
finish = "incomplete"
|
|
157
|
+
elif tool_calls:
|
|
153
158
|
finish = "tool_calls"
|
|
154
|
-
elif status == "incomplete":
|
|
155
|
-
finish = "length"
|
|
156
159
|
else:
|
|
157
160
|
finish = "stop"
|
|
158
161
|
message: dict = {"role": "assistant", "content": content}
|
|
@@ -245,4 +248,4 @@ class OpenAIResponsesProvider(BaseProvider):
|
|
|
245
248
|
raise_for_status(400, json.dumps({"error": state.error}), self.name)
|
|
246
249
|
raise ApiError(str(state.error))
|
|
247
250
|
return build_result(state.items, self.qualified(state.model or req.model), state.usage,
|
|
248
|
-
state.status, streamed_text="".join(state.text))
|
|
251
|
+
state.status or STREAM_ENDED, streamed_text="".join(state.text))
|
|
@@ -120,23 +120,41 @@ class ReplSession:
|
|
|
120
120
|
messages=self.messages,
|
|
121
121
|
)
|
|
122
122
|
|
|
123
|
+
def switch_root(self, root) -> None:
|
|
124
|
+
"""Move the session to another workspace (entering or leaving a worktree).
|
|
125
|
+
|
|
126
|
+
The config root, the file scope, and the hooks move together, so a worktree nested under the
|
|
127
|
+
original checkout does not keep the original in scope. Paths granted for the old root are
|
|
128
|
+
dropped rather than carried into the new one.
|
|
129
|
+
"""
|
|
130
|
+
from .tools.base import Scope
|
|
131
|
+
|
|
132
|
+
self.config = self.config.override(root=str(root))
|
|
133
|
+
self.scope = Scope(self.config.root, everything=(getattr(self.config, "scope", "workspace") == "anywhere"))
|
|
134
|
+
if getattr(self, "hooks", None) is not None:
|
|
135
|
+
from .hooks import Hooks
|
|
136
|
+
|
|
137
|
+
self.hooks = Hooks(self.config.root)
|
|
138
|
+
|
|
123
139
|
def apply_record(self, record) -> None:
|
|
124
|
-
"""Load a saved conversation
|
|
140
|
+
"""Load a saved conversation into this live session.
|
|
125
141
|
|
|
126
142
|
The saved model is adopted only when this run set nothing explicit (the preference is still
|
|
127
143
|
the ``default`` alias) and the saved model's provider is configured; an explicit ``--model`` /
|
|
128
144
|
``SCOOT_MODEL`` / saved preference for this run always wins over what the session recorded.
|
|
145
|
+
The approval mode is never restored: it is this run's permission policy, and resuming a
|
|
146
|
+
session that once ran in ``yolo`` must not quietly turn ``--approval always`` into ``yolo``.
|
|
129
147
|
"""
|
|
148
|
+
from .sessions import complete_tool_results
|
|
149
|
+
|
|
130
150
|
self.id = record.id
|
|
131
151
|
self.created = record.created or self.created
|
|
132
152
|
if self._may_adopt_saved_model(record.model, record.active_model):
|
|
133
153
|
self.model = record.model or self.model
|
|
134
154
|
self.active_model = record.active_model or self.active_model
|
|
135
|
-
if record.approval_mode in MODES:
|
|
136
|
-
self.approval_mode = record.approval_mode
|
|
137
155
|
self.total_prompt = record.total_prompt
|
|
138
156
|
self.total_completion = record.total_completion
|
|
139
|
-
self.messages = list(record.messages)
|
|
157
|
+
self.messages = complete_tool_results(list(record.messages))
|
|
140
158
|
self.resumed = True
|
|
141
159
|
|
|
142
160
|
def refresh_readiness(self) -> bool:
|
|
@@ -967,6 +985,14 @@ class Repl:
|
|
|
967
985
|
f"[{session.resolved_model()}] steps={outcome.steps} "
|
|
968
986
|
f"prompt={u.get('prompt_tokens', '?')} "
|
|
969
987
|
f"completion={u.get('completion_tokens', '?')}", "gray"))
|
|
988
|
+
elif outcome.status == "incomplete":
|
|
989
|
+
partial = outcome.content.strip()
|
|
990
|
+
session.last_output = partial
|
|
991
|
+
if partial and not outcome.streamed:
|
|
992
|
+
if self.labels and sys.stdout.isatty():
|
|
993
|
+
print(_assistant_label(self.ui.emoji))
|
|
994
|
+
print(partial)
|
|
995
|
+
print(color(f"⚠ reply cut off: {outcome.error}. ask it to continue, or split the task.", "yellow"))
|
|
970
996
|
elif outcome.status == "interrupted":
|
|
971
997
|
print(color("⏹ interrupted", "yellow"))
|
|
972
998
|
elif outcome.status == "aborted":
|
|
@@ -35,6 +35,35 @@ def new_session_id() -> str:
|
|
|
35
35
|
return time.strftime("%Y%m%d-%H%M%S") + "-" + os.urandom(2).hex()
|
|
36
36
|
|
|
37
37
|
|
|
38
|
+
def complete_tool_results(messages: List[dict]) -> List[dict]:
|
|
39
|
+
"""Return ``messages`` with a result present for every assistant tool call.
|
|
40
|
+
|
|
41
|
+
A turn interrupted mid-batch used to leave calls without results; providers reject such a
|
|
42
|
+
history on the next request. Missing results are filled with a note, in place, right after the
|
|
43
|
+
results that do exist, so the conversation replays.
|
|
44
|
+
"""
|
|
45
|
+
out: List[dict] = []
|
|
46
|
+
i = 0
|
|
47
|
+
while i < len(messages):
|
|
48
|
+
m = messages[i]
|
|
49
|
+
out.append(m)
|
|
50
|
+
i += 1
|
|
51
|
+
calls = m.get("tool_calls") if isinstance(m, dict) and m.get("role") == "assistant" else None
|
|
52
|
+
if not calls:
|
|
53
|
+
continue
|
|
54
|
+
answered = set()
|
|
55
|
+
while i < len(messages) and isinstance(messages[i], dict) and messages[i].get("role") == "tool":
|
|
56
|
+
answered.add(messages[i].get("tool_call_id"))
|
|
57
|
+
out.append(messages[i])
|
|
58
|
+
i += 1
|
|
59
|
+
for tc in calls:
|
|
60
|
+
tc_id = tc.get("id", "") if isinstance(tc, dict) else ""
|
|
61
|
+
if tc_id not in answered:
|
|
62
|
+
out.append({"role": "tool", "tool_call_id": tc_id,
|
|
63
|
+
"content": "no result recorded: the turn was interrupted before this call finished"})
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
38
67
|
def _redact_messages(messages: List[dict]) -> List[dict]:
|
|
39
68
|
"""Copy messages, masking token-like substrings in string content fields."""
|
|
40
69
|
cleaned: List[dict] = []
|