nooa-cli 0.0.8__tar.gz → 0.0.9__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.
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/.gitignore +2 -3
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/PKG-INFO +9 -1
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/README.md +8 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/AGENTS.md +29 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/__init__.py +10 -3
- nooa_cli-0.0.9/src/nooa_cli/coding/__init__.py +19 -0
- nooa_cli-0.0.9/src/nooa_cli/coding/activity.py +431 -0
- nooa_cli-0.0.9/src/nooa_cli/commands/__init__.py +208 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/commands/_otlp_helpers.py +55 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/commands/import_harbor.py +11 -3
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/commands/import_traces.py +11 -3
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/commands/start_dev.py +23 -0
- nooa_cli-0.0.9/src/nooa_cli/sessions/__init__.py +31 -0
- nooa_cli-0.0.9/src/nooa_cli/sessions/events.py +43 -0
- nooa_cli-0.0.9/src/nooa_cli/sessions/store.py +436 -0
- nooa_cli-0.0.9/tests/test_coding_activity.py +191 -0
- nooa_cli-0.0.9/tests/test_command_plugins.py +238 -0
- nooa_cli-0.0.9/tests/test_sessions.py +270 -0
- nooa_cli-0.0.8/src/nooa_cli/commands/__init__.py +0 -106
- nooa_cli-0.0.8/tests/__init__.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/docs/activity-introspection-design.md +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/pyproject.toml +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/__main__.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/_common.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/commands/_template.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/commands/config.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/commands/delete_traces.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/commands/eval.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/commands/traces.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/completion.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/tools/__init__.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/tools/_tree_sitter_backend.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/tools/pyp/__init__.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/tools/pyp/errors.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/tools/pyp/sources.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/tools/pyp/stream.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/tools/pyp/tests/__init__.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/tools/pyp/tests/test_pyp.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/src/nooa_cli/tools/repo_tools.py +0 -0
- {nooa_cli-0.0.8 → nooa_cli-0.0.9}/tests/test_cli_smoke.py +0 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# Rust toolchain (installed for
|
|
1
|
+
# Rust toolchain (installed for nemo_relay PyO3 build)
|
|
2
2
|
.cargo/
|
|
3
3
|
.rustup/
|
|
4
4
|
|
|
@@ -296,7 +296,7 @@ agents/**/traces/
|
|
|
296
296
|
.config/
|
|
297
297
|
.local/
|
|
298
298
|
|
|
299
|
-
# Rust toolchain (installed by rustup for nemo-
|
|
299
|
+
# Rust toolchain (installed by rustup for nemo-relay build)
|
|
300
300
|
.cargo/
|
|
301
301
|
.rustup/
|
|
302
302
|
|
|
@@ -366,7 +366,6 @@ sft_data/**/*.jsonl
|
|
|
366
366
|
|
|
367
367
|
# Git credentials (must not be committed)
|
|
368
368
|
.git-credentials
|
|
369
|
-
-e
|
|
370
369
|
# Bridge watchpoint file — per-worktree, never committed
|
|
371
370
|
.bridge_watch.json
|
|
372
371
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nooa-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.9
|
|
4
4
|
Summary: CLI for nemo-oo-agents (`nooa` command: eval runner, trace viewer, config)
|
|
5
5
|
Project-URL: Homepage, https://github.com/NVIDIA-NeMo/labs-OO-Agents
|
|
6
6
|
Project-URL: Repository, https://github.com/NVIDIA-NeMo/labs-OO-Agents
|
|
@@ -50,3 +50,11 @@ nooa traces ... # inspect/manage trace files
|
|
|
50
50
|
```
|
|
51
51
|
|
|
52
52
|
See the main repo [README](https://github.com/NVIDIA-NeMo/labs-OO-Agents/blob/main/README.md) for the framework documentation.
|
|
53
|
+
|
|
54
|
+
## Interactive coding sessions
|
|
55
|
+
|
|
56
|
+
`nooa_cli.sessions` owns durable coding-agent session identity, metadata, and
|
|
57
|
+
conversation replay shared by CLI hosts such as the native TUI and ACP. The
|
|
58
|
+
process running an agent owns the writable session handle; other hosts attach
|
|
59
|
+
through their transport or use read-only discovery. Generic event and SQLite
|
|
60
|
+
storage primitives remain in the core `nooa` package.
|
|
@@ -23,3 +23,11 @@ nooa traces ... # inspect/manage trace files
|
|
|
23
23
|
```
|
|
24
24
|
|
|
25
25
|
See the main repo [README](https://github.com/NVIDIA-NeMo/labs-OO-Agents/blob/main/README.md) for the framework documentation.
|
|
26
|
+
|
|
27
|
+
## Interactive coding sessions
|
|
28
|
+
|
|
29
|
+
`nooa_cli.sessions` owns durable coding-agent session identity, metadata, and
|
|
30
|
+
conversation replay shared by CLI hosts such as the native TUI and ACP. The
|
|
31
|
+
process running an agent owns the writable session handle; other hosts attach
|
|
32
|
+
through their transport or use read-only discovery. Generic event and SQLite
|
|
33
|
+
storage primitives remain in the core `nooa` package.
|
|
@@ -101,6 +101,35 @@ def command(args: tuple[str, ...]):
|
|
|
101
101
|
subprocess.run([sys.executable, "-m", "some_other_tool", *args])
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
+
## Commands From Another Package
|
|
105
|
+
|
|
106
|
+
If your command lives in a *different* installed package (not in this repo),
|
|
107
|
+
register it in the `nooa_cli.commands` entry-point group instead of dropping a
|
|
108
|
+
file here. The entry-point name becomes the subcommand name:
|
|
109
|
+
|
|
110
|
+
```toml
|
|
111
|
+
# pyproject.toml of your package
|
|
112
|
+
[project.entry-points."nooa_cli.commands"]
|
|
113
|
+
tui = "my_package.cli.tui:command"
|
|
114
|
+
term = "my_package.cli.term:command"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
`my_package.cli.tui:command` must be a `click.Command` or `click.Group` — the
|
|
118
|
+
same contract as an in-repo command module.
|
|
119
|
+
|
|
120
|
+
- **Built-ins win name collisions.** A plugin can't shadow `eval`, `config`,
|
|
121
|
+
or anything else shipped here; it's logged and skipped.
|
|
122
|
+
- **A broken plugin is skipped, not fatal.** An entry point that fails to
|
|
123
|
+
import, or that resolves to a non-`click.Command`, logs a warning and is
|
|
124
|
+
left out. `nooa` keeps working.
|
|
125
|
+
- Plugins register in entry-point-name order, so `nooa --help` is stable
|
|
126
|
+
regardless of install order.
|
|
127
|
+
- The **Performance Rule** below applies with extra force: every registered
|
|
128
|
+
entry point is loaded on *every* `nooa` invocation, including `nooa --help`.
|
|
129
|
+
Keep heavy imports inside the handler.
|
|
130
|
+
|
|
131
|
+
This mirrors the existing `nooa.skills` and `nooa.bundled_configs` groups.
|
|
132
|
+
|
|
104
133
|
## Shared Utilities
|
|
105
134
|
|
|
106
135
|
Common helpers live in `src/nooa_cli/_common.py`:
|
|
@@ -9,7 +9,10 @@ Usage:
|
|
|
9
9
|
nooa completion install # Set up shell completions
|
|
10
10
|
|
|
11
11
|
Adding new commands:
|
|
12
|
-
|
|
12
|
+
In this package: drop a .py file in nooa_cli/commands/ — see
|
|
13
|
+
commands/_template.py
|
|
14
|
+
From another package: register an entry point in the
|
|
15
|
+
"nooa_cli.commands" group — see nooa_cli/commands/__init__.py
|
|
13
16
|
|
|
14
17
|
Shell completion:
|
|
15
18
|
eval "$(_NOOA_COMPLETE=bash_source nooa)" # bash
|
|
@@ -36,7 +39,8 @@ def oo(ctx):
|
|
|
36
39
|
"""OO Agents — agent toolkit.
|
|
37
40
|
|
|
38
41
|
Extensible CLI for running agents, evaluations, and trace management.
|
|
39
|
-
Add new commands by dropping a .py file in nooa_cli/commands
|
|
42
|
+
Add new commands by dropping a .py file in nooa_cli/commands/, or from
|
|
43
|
+
another package via the "nooa_cli.commands" entry-point group.
|
|
40
44
|
"""
|
|
41
45
|
if ctx.invoked_subcommand in _SKIP_SECRETS_PRELOAD:
|
|
42
46
|
return
|
|
@@ -57,11 +61,14 @@ def oo(ctx):
|
|
|
57
61
|
)
|
|
58
62
|
|
|
59
63
|
|
|
60
|
-
# -- Auto-discover and register all commands
|
|
64
|
+
# -- Auto-discover and register all commands (built-ins + entry-point plugins) --
|
|
61
65
|
for _name, _cmd in discover_commands():
|
|
62
66
|
oo.add_command(_cmd, name=_name)
|
|
63
67
|
|
|
64
68
|
# -- Built-in infrastructure commands (not in commands/ because they're meta) -
|
|
69
|
+
# Registered *after* discovery on purpose: `completion` is not part of
|
|
70
|
+
# discover_commands(), so this ordering is what stops a third-party plugin
|
|
71
|
+
# named "completion" from taking the name.
|
|
65
72
|
oo.add_command(completion)
|
|
66
73
|
|
|
67
74
|
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Shared coding-agent components used by terminal and protocol hosts."""
|
|
4
|
+
|
|
5
|
+
from nooa_cli.coding.activity import (
|
|
6
|
+
ActivityShellTools,
|
|
7
|
+
FileEdit,
|
|
8
|
+
TerminalCommandFinished,
|
|
9
|
+
TerminalCommandOutput,
|
|
10
|
+
TerminalCommandStarted,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"ActivityShellTools",
|
|
15
|
+
"FileEdit",
|
|
16
|
+
"TerminalCommandFinished",
|
|
17
|
+
"TerminalCommandOutput",
|
|
18
|
+
"TerminalCommandStarted",
|
|
19
|
+
]
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Opt-in shell tools and events shared by interactive coding hosts."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from collections.abc import AsyncIterator
|
|
9
|
+
from difflib import unified_diff
|
|
10
|
+
from functools import wraps
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Annotated, Any, ClassVar, Literal
|
|
13
|
+
from uuid import uuid4
|
|
14
|
+
|
|
15
|
+
from pydantic import Field
|
|
16
|
+
|
|
17
|
+
from nooa.agentdoc import TruncatingStringIO, hidden, pformat, spec
|
|
18
|
+
from nooa.context_blocks import EventBase
|
|
19
|
+
from nooa.context_blocks.roles import Role
|
|
20
|
+
from nooa.runtime.event_manager import EventManager
|
|
21
|
+
from nooa.skill import Skill
|
|
22
|
+
from nooa.tools._bash_session import BashSession
|
|
23
|
+
from nooa.tools._results import StreamDone, StreamEvent
|
|
24
|
+
from nooa.tools.shell_tools import FileWrite, Match, ShellResult, ShellTools
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger(__name__)
|
|
27
|
+
|
|
28
|
+
_MAX_EVENT_TEXT_CHARS = 10_000
|
|
29
|
+
_MAX_COMMAND_OUTPUT_CHARS = 30_000
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _line_count(value: str) -> int:
|
|
33
|
+
if not value:
|
|
34
|
+
return 0
|
|
35
|
+
return value.count("\n") + (0 if value.endswith("\n") else 1)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _edit_diff(path: str, old_text: str, new_text: str, start_line: int | None) -> str:
|
|
39
|
+
lines = list(
|
|
40
|
+
unified_diff(
|
|
41
|
+
old_text.splitlines(keepends=True),
|
|
42
|
+
new_text.splitlines(keepends=True),
|
|
43
|
+
fromfile=f"a/{path}",
|
|
44
|
+
tofile=f"b/{path}",
|
|
45
|
+
)
|
|
46
|
+
)
|
|
47
|
+
if start_line is not None:
|
|
48
|
+
for index, line in enumerate(lines):
|
|
49
|
+
if line.startswith("@@ "):
|
|
50
|
+
old_count = max(1, _line_count(old_text))
|
|
51
|
+
new_count = max(1, _line_count(new_text))
|
|
52
|
+
lines[index] = f"@@ -{start_line},{old_count} +{start_line},{new_count} @@\n"
|
|
53
|
+
break
|
|
54
|
+
return pformat("".join(lines), max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class FileEdit(EventBase): # type: ignore[misc]
|
|
58
|
+
"""A successful structured filesystem edit made by the coding agent."""
|
|
59
|
+
|
|
60
|
+
_role: ClassVar[Role] = Role.RUNTIME_EVENT
|
|
61
|
+
|
|
62
|
+
path: Annotated[str, Field(description="Absolute path of the edited file")]
|
|
63
|
+
operation: Annotated[
|
|
64
|
+
Literal["create", "update"],
|
|
65
|
+
Field(description="Whether the file was created or updated"),
|
|
66
|
+
]
|
|
67
|
+
old_text: Annotated[
|
|
68
|
+
str | None,
|
|
69
|
+
Field(description="Bounded affected text before the edit, when available"),
|
|
70
|
+
] = None
|
|
71
|
+
new_text: Annotated[str, Field(description="Bounded affected text after the edit")] = ""
|
|
72
|
+
start_line: Annotated[
|
|
73
|
+
int | None,
|
|
74
|
+
Field(description="First affected line in the original file, 1-indexed"),
|
|
75
|
+
] = None
|
|
76
|
+
end_line: Annotated[
|
|
77
|
+
int | None,
|
|
78
|
+
Field(description="Last affected line in the original file, 1-indexed and inclusive"),
|
|
79
|
+
] = None
|
|
80
|
+
diff: Annotated[str, Field(description="Bounded unified diff of the affected text")] = ""
|
|
81
|
+
content_complete: Annotated[
|
|
82
|
+
bool,
|
|
83
|
+
Field(description="Whether old_text and new_text contain the complete affected text"),
|
|
84
|
+
] = True
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TerminalCommandStarted(EventBase): # type: ignore[misc]
|
|
88
|
+
"""A command began in a persistent coding-agent terminal."""
|
|
89
|
+
|
|
90
|
+
_role: ClassVar[Role] = Role.RUNTIME_EVENT
|
|
91
|
+
|
|
92
|
+
command_id: Annotated[str, Field(description="Correlation ID for this command")]
|
|
93
|
+
command: Annotated[str, Field(description="Bounded shell command text")]
|
|
94
|
+
working_directory: Annotated[str, Field(description="Working directory at command start")]
|
|
95
|
+
stdin: Annotated[
|
|
96
|
+
str | None,
|
|
97
|
+
Field(description="Bounded separate stdin text; never stored in session history"),
|
|
98
|
+
] = None
|
|
99
|
+
command_truncated: Annotated[
|
|
100
|
+
bool,
|
|
101
|
+
Field(description="Whether command was truncated by pformat for this event"),
|
|
102
|
+
] = False
|
|
103
|
+
stdin_truncated: Annotated[
|
|
104
|
+
bool,
|
|
105
|
+
Field(description="Whether stdin was truncated by pformat for this event"),
|
|
106
|
+
] = False
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class TerminalCommandOutput(EventBase): # type: ignore[misc]
|
|
110
|
+
"""One streaming output chunk from a coding-agent terminal command."""
|
|
111
|
+
|
|
112
|
+
_role: ClassVar[Role] = Role.RUNTIME_EVENT
|
|
113
|
+
|
|
114
|
+
command_id: Annotated[str, Field(description="Correlation ID for this command")]
|
|
115
|
+
stdout: Annotated[str, Field(description="Output chunk received on stdout")] = ""
|
|
116
|
+
stderr: Annotated[str, Field(description="Output chunk received on stderr")] = ""
|
|
117
|
+
truncated: Annotated[
|
|
118
|
+
bool,
|
|
119
|
+
Field(description="Whether further command output was omitted from activity events"),
|
|
120
|
+
] = False
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class TerminalCommandFinished(EventBase): # type: ignore[misc]
|
|
124
|
+
"""A coding-agent terminal command completed or failed to launch."""
|
|
125
|
+
|
|
126
|
+
_role: ClassVar[Role] = Role.RUNTIME_EVENT
|
|
127
|
+
|
|
128
|
+
command_id: Annotated[str, Field(description="Correlation ID for this command")]
|
|
129
|
+
exit_code: Annotated[int | None, Field(description="Process exit code when available")] = None
|
|
130
|
+
timed_out: Annotated[bool, Field(description="Whether the command timed out")] = False
|
|
131
|
+
error: Annotated[str, Field(description="Failure before an exit code was available")] = ""
|
|
132
|
+
output_truncated: Annotated[
|
|
133
|
+
bool,
|
|
134
|
+
Field(description="Whether output was omitted from command activity events"),
|
|
135
|
+
] = False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
class ActivityShellTools(Skill):
|
|
139
|
+
"""A composed ``ShellTools`` substitute that emits transient activity events.
|
|
140
|
+
|
|
141
|
+
Interactive agents opt into this class explicitly. The underlying
|
|
142
|
+
``ShellTools`` remains independent of event management and host UX.
|
|
143
|
+
"""
|
|
144
|
+
|
|
145
|
+
def __init__(
|
|
146
|
+
self,
|
|
147
|
+
shell: ShellTools,
|
|
148
|
+
event_manager: EventManager,
|
|
149
|
+
):
|
|
150
|
+
super().__init__()
|
|
151
|
+
self._shell = shell
|
|
152
|
+
self._event_manager = event_manager
|
|
153
|
+
|
|
154
|
+
def __repr__(self) -> str:
|
|
155
|
+
return f"ActivityShellTools(cwd={self.cwd!s})"
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
@hidden
|
|
159
|
+
def cwd(self) -> Path:
|
|
160
|
+
"""Current working directory of the wrapped persistent shell."""
|
|
161
|
+
return self._shell.cwd
|
|
162
|
+
|
|
163
|
+
@cwd.setter
|
|
164
|
+
def cwd(self, value: str | Path) -> None:
|
|
165
|
+
self._shell.cwd = Path(value)
|
|
166
|
+
|
|
167
|
+
@property
|
|
168
|
+
@hidden
|
|
169
|
+
def session(self) -> BashSession:
|
|
170
|
+
"""The wrapped shell's persistent bash session."""
|
|
171
|
+
return self._shell.session
|
|
172
|
+
|
|
173
|
+
@hidden
|
|
174
|
+
async def close(self) -> None:
|
|
175
|
+
"""Close the wrapped shell."""
|
|
176
|
+
await self._shell.close()
|
|
177
|
+
|
|
178
|
+
def _resolve_path(self, path: str) -> Path:
|
|
179
|
+
return self._shell._resolve_path(path)
|
|
180
|
+
|
|
181
|
+
def _emit(self, event: EventBase) -> None:
|
|
182
|
+
try:
|
|
183
|
+
self._event_manager.add(event)
|
|
184
|
+
except Exception:
|
|
185
|
+
logger.debug("Failed to emit shell activity", exc_info=True)
|
|
186
|
+
|
|
187
|
+
@wraps(ShellTools.run)
|
|
188
|
+
async def run(
|
|
189
|
+
self,
|
|
190
|
+
command: Annotated[str, spec(description="Shell command to execute")],
|
|
191
|
+
*,
|
|
192
|
+
stdin: Annotated[
|
|
193
|
+
str | None, spec(description="Text piped to stdin (replaces heredocs)")
|
|
194
|
+
] = None,
|
|
195
|
+
timeout: Annotated[float, spec(description="Max seconds")] = 30.0,
|
|
196
|
+
) -> ShellResult:
|
|
197
|
+
command_id = str(uuid4())
|
|
198
|
+
bounded_command = pformat(command, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
199
|
+
command_truncated = len(command) > _MAX_EVENT_TEXT_CHARS
|
|
200
|
+
bounded_stdin: str | None = None
|
|
201
|
+
stdin_truncated = False
|
|
202
|
+
if stdin is not None:
|
|
203
|
+
bounded_stdin = pformat(stdin, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
204
|
+
stdin_truncated = len(stdin) > _MAX_EVENT_TEXT_CHARS
|
|
205
|
+
self._emit(
|
|
206
|
+
TerminalCommandStarted(
|
|
207
|
+
command_id=command_id,
|
|
208
|
+
command=bounded_command,
|
|
209
|
+
working_directory=str(self.cwd),
|
|
210
|
+
stdin=bounded_stdin,
|
|
211
|
+
command_truncated=command_truncated,
|
|
212
|
+
stdin_truncated=stdin_truncated,
|
|
213
|
+
)
|
|
214
|
+
)
|
|
215
|
+
try:
|
|
216
|
+
result = await self._shell.run(command, stdin=stdin, timeout=timeout)
|
|
217
|
+
except BaseException as error:
|
|
218
|
+
self._emit(
|
|
219
|
+
TerminalCommandFinished(
|
|
220
|
+
command_id=command_id,
|
|
221
|
+
error=str(error) or type(error).__name__,
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
raise
|
|
225
|
+
|
|
226
|
+
stdout_buffer = TruncatingStringIO(limit=_MAX_COMMAND_OUTPUT_CHARS // 2)
|
|
227
|
+
stderr_buffer = TruncatingStringIO(limit=_MAX_COMMAND_OUTPUT_CHARS // 2)
|
|
228
|
+
stdout_buffer.write(result.stdout)
|
|
229
|
+
stderr_buffer.write(result.stderr)
|
|
230
|
+
output_truncated = stdout_buffer.was_truncated or stderr_buffer.was_truncated
|
|
231
|
+
if result.stdout or result.stderr:
|
|
232
|
+
self._emit(
|
|
233
|
+
TerminalCommandOutput(
|
|
234
|
+
command_id=command_id,
|
|
235
|
+
stdout=stdout_buffer.getvalue(),
|
|
236
|
+
stderr=stderr_buffer.getvalue(),
|
|
237
|
+
truncated=output_truncated,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
self._emit(
|
|
241
|
+
TerminalCommandFinished(
|
|
242
|
+
command_id=command_id,
|
|
243
|
+
exit_code=result.returncode,
|
|
244
|
+
timed_out=result.timed_out,
|
|
245
|
+
output_truncated=output_truncated,
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
return result
|
|
249
|
+
|
|
250
|
+
@wraps(ShellTools.run_stream)
|
|
251
|
+
async def run_stream(
|
|
252
|
+
self,
|
|
253
|
+
command: Annotated[str, spec(description="Shell command to execute")],
|
|
254
|
+
timeout: Annotated[float, spec(description="Max seconds to wait before timeout")] = 30.0,
|
|
255
|
+
) -> AsyncIterator[StreamEvent | StreamDone]:
|
|
256
|
+
command_id = str(uuid4())
|
|
257
|
+
bounded_command = pformat(command, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
258
|
+
command_truncated = len(command) > _MAX_EVENT_TEXT_CHARS
|
|
259
|
+
self._emit(
|
|
260
|
+
TerminalCommandStarted(
|
|
261
|
+
command_id=command_id,
|
|
262
|
+
command=bounded_command,
|
|
263
|
+
working_directory=str(self.cwd),
|
|
264
|
+
command_truncated=command_truncated,
|
|
265
|
+
)
|
|
266
|
+
)
|
|
267
|
+
finished = False
|
|
268
|
+
stdout_buffer = TruncatingStringIO(limit=_MAX_COMMAND_OUTPUT_CHARS // 2)
|
|
269
|
+
stderr_buffer = TruncatingStringIO(limit=_MAX_COMMAND_OUTPUT_CHARS // 2)
|
|
270
|
+
stream = self._shell.run_stream(command, timeout=timeout)
|
|
271
|
+
try:
|
|
272
|
+
async for item in stream:
|
|
273
|
+
if isinstance(item, StreamDone):
|
|
274
|
+
output_truncated = stdout_buffer.was_truncated or stderr_buffer.was_truncated
|
|
275
|
+
if stdout_buffer.chars_written or stderr_buffer.chars_written:
|
|
276
|
+
self._emit(
|
|
277
|
+
TerminalCommandOutput(
|
|
278
|
+
command_id=command_id,
|
|
279
|
+
stdout=stdout_buffer.getvalue(),
|
|
280
|
+
stderr=stderr_buffer.getvalue(),
|
|
281
|
+
truncated=output_truncated,
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
self._emit(
|
|
285
|
+
TerminalCommandFinished(
|
|
286
|
+
command_id=command_id,
|
|
287
|
+
exit_code=item.returncode,
|
|
288
|
+
timed_out=item.timed_out,
|
|
289
|
+
output_truncated=output_truncated,
|
|
290
|
+
)
|
|
291
|
+
)
|
|
292
|
+
finished = True
|
|
293
|
+
else:
|
|
294
|
+
buffer = stdout_buffer if item.kind == "stdout" else stderr_buffer
|
|
295
|
+
buffer.write(item.text)
|
|
296
|
+
yield item
|
|
297
|
+
except BaseException as error:
|
|
298
|
+
if not finished:
|
|
299
|
+
output_truncated = stdout_buffer.was_truncated or stderr_buffer.was_truncated
|
|
300
|
+
if stdout_buffer.chars_written or stderr_buffer.chars_written:
|
|
301
|
+
self._emit(
|
|
302
|
+
TerminalCommandOutput(
|
|
303
|
+
command_id=command_id,
|
|
304
|
+
stdout=stdout_buffer.getvalue(),
|
|
305
|
+
stderr=stderr_buffer.getvalue(),
|
|
306
|
+
truncated=output_truncated,
|
|
307
|
+
)
|
|
308
|
+
)
|
|
309
|
+
self._emit(
|
|
310
|
+
TerminalCommandFinished(
|
|
311
|
+
command_id=command_id,
|
|
312
|
+
error=str(error) or type(error).__name__,
|
|
313
|
+
output_truncated=output_truncated,
|
|
314
|
+
)
|
|
315
|
+
)
|
|
316
|
+
raise
|
|
317
|
+
finally:
|
|
318
|
+
await stream.aclose()
|
|
319
|
+
|
|
320
|
+
@wraps(ShellTools.read)
|
|
321
|
+
async def read(
|
|
322
|
+
self,
|
|
323
|
+
path: Annotated[str, spec(description="File path (relative to cwd)")],
|
|
324
|
+
lines: Annotated[
|
|
325
|
+
tuple[int, int] | None,
|
|
326
|
+
spec(description="(start, end) 1-indexed inclusive, or None for whole file"),
|
|
327
|
+
] = None,
|
|
328
|
+
) -> Match:
|
|
329
|
+
return await self._shell.read(path, lines)
|
|
330
|
+
|
|
331
|
+
@wraps(ShellTools.replace)
|
|
332
|
+
async def replace(
|
|
333
|
+
self,
|
|
334
|
+
target: Annotated[
|
|
335
|
+
Any, spec(description="A Match (from read() or run().matches) or a file path string")
|
|
336
|
+
],
|
|
337
|
+
old_or_new: Annotated[
|
|
338
|
+
str,
|
|
339
|
+
spec(
|
|
340
|
+
description="For Match: replacement text. For path: text to find (must be unique)"
|
|
341
|
+
),
|
|
342
|
+
] = "",
|
|
343
|
+
new: Annotated[
|
|
344
|
+
str | None, spec(description="For path: replacement text. Leave None for Match.")
|
|
345
|
+
] = None,
|
|
346
|
+
) -> FileWrite:
|
|
347
|
+
path = target.path if isinstance(target, Match) else target
|
|
348
|
+
if not isinstance(path, str):
|
|
349
|
+
return await self._shell.replace(target, old_or_new, new)
|
|
350
|
+
|
|
351
|
+
resolved = self._resolve_path(path)
|
|
352
|
+
if isinstance(target, Match):
|
|
353
|
+
old_text = target.text
|
|
354
|
+
start_line = target.start
|
|
355
|
+
end_line = target.end
|
|
356
|
+
else:
|
|
357
|
+
old_text = old_or_new
|
|
358
|
+
start_line = None
|
|
359
|
+
end_line = None
|
|
360
|
+
try:
|
|
361
|
+
with resolved.open("r") as stream:
|
|
362
|
+
content = stream.read(_MAX_EVENT_TEXT_CHARS + 1)
|
|
363
|
+
if len(content) <= _MAX_EVENT_TEXT_CHARS:
|
|
364
|
+
offset = content.index(old_text)
|
|
365
|
+
start_line = content.count("\n", 0, offset) + 1
|
|
366
|
+
end_line = start_line + max(1, _line_count(old_text)) - 1
|
|
367
|
+
except (OSError, UnicodeError, ValueError):
|
|
368
|
+
pass
|
|
369
|
+
|
|
370
|
+
bounded_old = pformat(old_text, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
371
|
+
old_truncated = len(old_text) > _MAX_EVENT_TEXT_CHARS
|
|
372
|
+
result = await self._shell.replace(target, old_or_new, new)
|
|
373
|
+
# replace() may re-terminate the region, so report what it wrote rather
|
|
374
|
+
# than what it was handed.
|
|
375
|
+
written_text = result.new_text
|
|
376
|
+
bounded_new = pformat(written_text, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
377
|
+
new_truncated = len(written_text) > _MAX_EVENT_TEXT_CHARS
|
|
378
|
+
self._emit(
|
|
379
|
+
FileEdit(
|
|
380
|
+
path=str(resolved),
|
|
381
|
+
operation="update",
|
|
382
|
+
old_text=bounded_old,
|
|
383
|
+
new_text=bounded_new,
|
|
384
|
+
start_line=start_line,
|
|
385
|
+
end_line=end_line,
|
|
386
|
+
diff=_edit_diff(path, bounded_old, bounded_new, start_line),
|
|
387
|
+
content_complete=not old_truncated and not new_truncated,
|
|
388
|
+
)
|
|
389
|
+
)
|
|
390
|
+
return result
|
|
391
|
+
|
|
392
|
+
@wraps(ShellTools.write_file)
|
|
393
|
+
async def write_file(
|
|
394
|
+
self,
|
|
395
|
+
path: Annotated[str, spec(description="File path (relative to cwd)")],
|
|
396
|
+
content: Annotated[str, spec(description="Full file content")],
|
|
397
|
+
) -> FileWrite:
|
|
398
|
+
resolved = self._resolve_path(path)
|
|
399
|
+
existed = resolved.exists()
|
|
400
|
+
old_text: str | None = None
|
|
401
|
+
old_complete = True
|
|
402
|
+
if existed:
|
|
403
|
+
try:
|
|
404
|
+
with resolved.open("r") as stream:
|
|
405
|
+
old_text = stream.read(_MAX_EVENT_TEXT_CHARS + 1)
|
|
406
|
+
old_complete = len(old_text) <= _MAX_EVENT_TEXT_CHARS
|
|
407
|
+
old_text = pformat(old_text, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
408
|
+
except (OSError, UnicodeError):
|
|
409
|
+
# Observation must not make an otherwise valid overwrite fail.
|
|
410
|
+
old_complete = False
|
|
411
|
+
|
|
412
|
+
result = await self._shell.write_file(path, content)
|
|
413
|
+
bounded_content = pformat(content, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
414
|
+
content_truncated = len(content) > _MAX_EVENT_TEXT_CHARS
|
|
415
|
+
self._emit(
|
|
416
|
+
FileEdit(
|
|
417
|
+
path=str(resolved),
|
|
418
|
+
operation="update" if existed else "create",
|
|
419
|
+
old_text=old_text,
|
|
420
|
+
new_text=bounded_content,
|
|
421
|
+
start_line=1 if existed and old_complete and old_text is not None else None,
|
|
422
|
+
end_line=(
|
|
423
|
+
max(1, _line_count(old_text))
|
|
424
|
+
if existed and old_complete and old_text is not None
|
|
425
|
+
else None
|
|
426
|
+
),
|
|
427
|
+
diff=_edit_diff(path, old_text or "", bounded_content, 1),
|
|
428
|
+
content_complete=old_complete and not content_truncated,
|
|
429
|
+
)
|
|
430
|
+
)
|
|
431
|
+
return result
|