nooa-cli 0.0.9__tar.gz → 0.0.11__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.9 → nooa_cli-0.0.11}/PKG-INFO +18 -5
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/README.md +15 -3
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/pyproject.toml +1 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/AGENTS.md +7 -2
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/__init__.py +53 -0
- nooa_cli-0.0.11/src/nooa_cli/coding/__init__.py +43 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/coding/activity.py +166 -38
- nooa_cli-0.0.11/src/nooa_cli/coding/agent.py +228 -0
- nooa_cli-0.0.11/src/nooa_cli/coding/instructions.py +80 -0
- nooa_cli-0.0.11/src/nooa_cli/coding/settings.py +140 -0
- nooa_cli-0.0.11/src/nooa_cli/coding/slash_commands.py +124 -0
- nooa_cli-0.0.11/src/nooa_cli/commands/_connect_advertised_media.py +142 -0
- nooa_cli-0.0.11/src/nooa_cli/commands/_connect_input_policy.py +124 -0
- nooa_cli-0.0.11/src/nooa_cli/commands/_connect_prompts.py +713 -0
- nooa_cli-0.0.11/src/nooa_cli/commands/_connect_registry.py +257 -0
- nooa_cli-0.0.11/src/nooa_cli/commands/_connect_stages.py +844 -0
- nooa_cli-0.0.11/src/nooa_cli/commands/_connect_view.py +897 -0
- nooa_cli-0.0.11/src/nooa_cli/commands/_connect_wizard.py +3912 -0
- nooa_cli-0.0.11/src/nooa_cli/commands/_otlp_helpers.py +387 -0
- nooa_cli-0.0.11/src/nooa_cli/commands/connect.py +481 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/delete_traces.py +11 -6
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/import_harbor.py +11 -2
- nooa_cli-0.0.11/src/nooa_cli/commands/import_traces.py +406 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/_tree_sitter_backend.py +21 -10
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/repo_tools.py +13 -2
- nooa_cli-0.0.11/tests/conftest.py +29 -0
- nooa_cli-0.0.11/tests/test_cli_platform_check.py +37 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/tests/test_cli_smoke.py +18 -0
- nooa_cli-0.0.11/tests/test_cli_utf8_output.py +57 -0
- nooa_cli-0.0.11/tests/test_coding_activity.py +442 -0
- nooa_cli-0.0.11/tests/test_coding_agent.py +278 -0
- nooa_cli-0.0.11/tests/test_coding_settings.py +229 -0
- nooa_cli-0.0.11/tests/test_coding_slash_commands.py +213 -0
- nooa_cli-0.0.11/tests/test_connect_advertised_media.py +602 -0
- nooa_cli-0.0.11/tests/test_connect_baseline_safety.py +240 -0
- nooa_cli-0.0.11/tests/test_connect_cache_control.py +272 -0
- nooa_cli-0.0.11/tests/test_connect_change_settings.py +321 -0
- nooa_cli-0.0.11/tests/test_connect_checkbox_pty.py +193 -0
- nooa_cli-0.0.11/tests/test_connect_coderabbit_budget.py +60 -0
- nooa_cli-0.0.11/tests/test_connect_command.py +1767 -0
- nooa_cli-0.0.11/tests/test_connect_current_verification.py +145 -0
- nooa_cli-0.0.11/tests/test_connect_decision_catalogue.py +230 -0
- nooa_cli-0.0.11/tests/test_connect_decision_listing.py +471 -0
- nooa_cli-0.0.11/tests/test_connect_decision_smoke.py +213 -0
- nooa_cli-0.0.11/tests/test_connect_diagnostic.py +118 -0
- nooa_cli-0.0.11/tests/test_connect_docs.py +59 -0
- nooa_cli-0.0.11/tests/test_connect_docs_contract.py +695 -0
- nooa_cli-0.0.11/tests/test_connect_edit.py +307 -0
- nooa_cli-0.0.11/tests/test_connect_encrypted_reasoning.py +168 -0
- nooa_cli-0.0.11/tests/test_connect_endpoint_scope.py +45 -0
- nooa_cli-0.0.11/tests/test_connect_entrypoint.py +86 -0
- nooa_cli-0.0.11/tests/test_connect_exact_context.py +215 -0
- nooa_cli-0.0.11/tests/test_connect_initial_order.py +469 -0
- nooa_cli-0.0.11/tests/test_connect_initial_order_pty.py +161 -0
- nooa_cli-0.0.11/tests/test_connect_interface_selection.py +778 -0
- nooa_cli-0.0.11/tests/test_connect_listing_provenance.py +340 -0
- nooa_cli-0.0.11/tests/test_connect_live_followups.py +134 -0
- nooa_cli-0.0.11/tests/test_connect_manual_limits.py +623 -0
- nooa_cli-0.0.11/tests/test_connect_media_inputs.py +119 -0
- nooa_cli-0.0.11/tests/test_connect_media_onboarding.py +965 -0
- nooa_cli-0.0.11/tests/test_connect_media_review.py +571 -0
- nooa_cli-0.0.11/tests/test_connect_metadata_first.py +663 -0
- nooa_cli-0.0.11/tests/test_connect_model_first.py +450 -0
- nooa_cli-0.0.11/tests/test_connect_native_media_policy.py +348 -0
- nooa_cli-0.0.11/tests/test_connect_native_profile.py +270 -0
- nooa_cli-0.0.11/tests/test_connect_practical_ui.py +525 -0
- nooa_cli-0.0.11/tests/test_connect_prompts.py +197 -0
- nooa_cli-0.0.11/tests/test_connect_proposal_layout.py +120 -0
- nooa_cli-0.0.11/tests/test_connect_real_pty.py +176 -0
- nooa_cli-0.0.11/tests/test_connect_reasoning_choice.py +397 -0
- nooa_cli-0.0.11/tests/test_connect_reply_budget.py +309 -0
- nooa_cli-0.0.11/tests/test_connect_review_followups.py +233 -0
- nooa_cli-0.0.11/tests/test_connect_review_readiness.py +115 -0
- nooa_cli-0.0.11/tests/test_connect_save_policy.py +769 -0
- nooa_cli-0.0.11/tests/test_connect_serial_ux.py +662 -0
- nooa_cli-0.0.11/tests/test_connect_server_details.py +768 -0
- nooa_cli-0.0.11/tests/test_connect_severin_review.py +83 -0
- nooa_cli-0.0.11/tests/test_connect_skill.py +227 -0
- nooa_cli-0.0.11/tests/test_connect_stages.py +521 -0
- nooa_cli-0.0.11/tests/test_connect_transcripts.py +252 -0
- nooa_cli-0.0.11/tests/test_connect_ux.py +443 -0
- nooa_cli-0.0.11/tests/test_connect_view.py +1159 -0
- nooa_cli-0.0.11/tests/test_connect_windows.py +79 -0
- nooa_cli-0.0.11/tests/test_import_traces.py +576 -0
- nooa_cli-0.0.11/tests/test_tree_sitter_backend.py +85 -0
- nooa_cli-0.0.9/src/nooa_cli/coding/__init__.py +0 -19
- nooa_cli-0.0.9/src/nooa_cli/commands/_otlp_helpers.py +0 -177
- nooa_cli-0.0.9/src/nooa_cli/commands/import_traces.py +0 -197
- nooa_cli-0.0.9/tests/test_coding_activity.py +0 -191
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/.gitignore +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/docs/activity-introspection-design.md +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/__main__.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/_common.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/__init__.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/_template.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/config.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/eval.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/start_dev.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/commands/traces.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/completion.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/sessions/__init__.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/sessions/events.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/sessions/store.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/__init__.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/pyp/__init__.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/pyp/errors.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/pyp/sources.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/pyp/stream.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/pyp/tests/__init__.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/src/nooa_cli/tools/pyp/tests/test_pyp.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/tests/test_command_plugins.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.11}/tests/test_sessions.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
2
|
Name: nooa-cli
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.11
|
|
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
|
|
@@ -9,6 +9,7 @@ License: Apache-2.0
|
|
|
9
9
|
Requires-Python: <3.14,>=3.12
|
|
10
10
|
Requires-Dist: click>=8.1.0
|
|
11
11
|
Requires-Dist: nooa
|
|
12
|
+
Requires-Dist: prompt-toolkit>=3.0.52
|
|
12
13
|
Requires-Dist: pyyaml>=6.0
|
|
13
14
|
Provides-Extra: ast
|
|
14
15
|
Requires-Dist: tree-sitter-go>=0.23.0; extra == 'ast'
|
|
@@ -40,13 +41,25 @@ uv add "nooa-cli[datascience]"
|
|
|
40
41
|
|
|
41
42
|
`nooa-cli` automatically pulls in matching `nemo-oo-agents` (the core framework). The `[datascience]` extra adds libraries the LLM can use in REPL-generated code.
|
|
42
43
|
|
|
44
|
+
> **Windows:** use [WSL 2](https://learn.microsoft.com/windows/wsl/install). Native Windows is not supported; see the [main README](https://github.com/NVIDIA-NeMo/labs-OO-Agents#installation).
|
|
45
|
+
|
|
43
46
|
## Usage
|
|
44
47
|
|
|
45
48
|
```bash
|
|
46
49
|
nooa --help
|
|
47
|
-
nooa start-dev
|
|
48
|
-
nooa eval ...
|
|
49
|
-
nooa traces ...
|
|
50
|
+
nooa start-dev # launch the trace viewer
|
|
51
|
+
nooa eval ... # eval pipeline runner
|
|
52
|
+
nooa traces ... # inspect/manage trace files
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Install the separate `nooa-acp` package to add the `nooa acp` plugin command and
|
|
56
|
+
run the NOOA coding agent from an ACP-compatible client:
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
uv add nooa-acp
|
|
60
|
+
export NOOA_MODEL=nvidia_nim/nvidia/nemotron-3-super-120b-a12b
|
|
61
|
+
export NVIDIA_API_KEY=nvapi-...
|
|
62
|
+
uv run nooa-acp
|
|
50
63
|
```
|
|
51
64
|
|
|
52
65
|
See the main repo [README](https://github.com/NVIDIA-NeMo/labs-OO-Agents/blob/main/README.md) for the framework documentation.
|
|
@@ -13,13 +13,25 @@ uv add "nooa-cli[datascience]"
|
|
|
13
13
|
|
|
14
14
|
`nooa-cli` automatically pulls in matching `nemo-oo-agents` (the core framework). The `[datascience]` extra adds libraries the LLM can use in REPL-generated code.
|
|
15
15
|
|
|
16
|
+
> **Windows:** use [WSL 2](https://learn.microsoft.com/windows/wsl/install). Native Windows is not supported; see the [main README](https://github.com/NVIDIA-NeMo/labs-OO-Agents#installation).
|
|
17
|
+
|
|
16
18
|
## Usage
|
|
17
19
|
|
|
18
20
|
```bash
|
|
19
21
|
nooa --help
|
|
20
|
-
nooa start-dev
|
|
21
|
-
nooa eval ...
|
|
22
|
-
nooa traces ...
|
|
22
|
+
nooa start-dev # launch the trace viewer
|
|
23
|
+
nooa eval ... # eval pipeline runner
|
|
24
|
+
nooa traces ... # inspect/manage trace files
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
Install the separate `nooa-acp` package to add the `nooa acp` plugin command and
|
|
28
|
+
run the NOOA coding agent from an ACP-compatible client:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
uv add nooa-acp
|
|
32
|
+
export NOOA_MODEL=nvidia_nim/nvidia/nemotron-3-super-120b-a12b
|
|
33
|
+
export NVIDIA_API_KEY=nvapi-...
|
|
34
|
+
uv run nooa-acp
|
|
23
35
|
```
|
|
24
36
|
|
|
25
37
|
See the main repo [README](https://github.com/NVIDIA-NeMo/labs-OO-Agents/blob/main/README.md) for the framework documentation.
|
|
@@ -127,6 +127,11 @@ same contract as an in-repo command module.
|
|
|
127
127
|
- The **Performance Rule** below applies with extra force: every registered
|
|
128
128
|
entry point is loaded on *every* `nooa` invocation, including `nooa --help`.
|
|
129
129
|
Keep heavy imports inside the handler.
|
|
130
|
+
- **Importing `nooa_cli` must not import `nooa`** (`test_connect_entrypoint.py`
|
|
131
|
+
checks this). `main()` checks the native-Windows policy itself
|
|
132
|
+
(`_refuse_unsupported_platform`, kept equal to `nooa._platform` by
|
|
133
|
+
`packages/nooa-cli/tests/test_cli_platform_check.py`) and switches redirected output to UTF-8
|
|
134
|
+
on Windows. New console entry points should go through `main()`.
|
|
130
135
|
|
|
131
136
|
This mirrors the existing `nooa.skills` and `nooa.bundled_configs` groups.
|
|
132
137
|
|
|
@@ -186,6 +191,6 @@ New commands get tab-completion automatically. To enable it:
|
|
|
186
191
|
nooa completion install # auto-detects your shell
|
|
187
192
|
|
|
188
193
|
# Or manually:
|
|
189
|
-
eval "$(
|
|
190
|
-
eval "$(
|
|
194
|
+
eval "$(_NOOA_COMPLETE=zsh_source nooa)" # zsh
|
|
195
|
+
eval "$(_NOOA_COMPLETE=bash_source nooa)" # bash
|
|
191
196
|
```
|
|
@@ -19,6 +19,9 @@ Shell completion:
|
|
|
19
19
|
eval "$(_NOOA_COMPLETE=zsh_source nooa)" # zsh
|
|
20
20
|
"""
|
|
21
21
|
|
|
22
|
+
import os
|
|
23
|
+
import sys
|
|
24
|
+
|
|
22
25
|
import click
|
|
23
26
|
|
|
24
27
|
from .commands import discover_commands
|
|
@@ -72,6 +75,56 @@ for _name, _cmd in discover_commands():
|
|
|
72
75
|
oo.add_command(completion)
|
|
73
76
|
|
|
74
77
|
|
|
78
|
+
# Same policy and wording as nooa._platform, checked here without importing
|
|
79
|
+
# nooa so the CLI stays lazy (packages/nooa-cli/tests/test_cli_platform_check.py keeps them equal).
|
|
80
|
+
_SKIP_PLATFORM_CHECK_ENV = "NOOA_SKIP_PLATFORM_CHECK"
|
|
81
|
+
_UNSUPPORTED_PLATFORM_MESSAGE = (
|
|
82
|
+
"nooa does not support native Windows; use WSL 2 "
|
|
83
|
+
"(https://learn.microsoft.com/windows/wsl/install). "
|
|
84
|
+
f"Setting the {_SKIP_PLATFORM_CHECK_ENV} environment variable to 1 skips this check: "
|
|
85
|
+
"the pure-Python core and `nooa connect` are tested on native Windows; "
|
|
86
|
+
"other features may work but are unsupported."
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _refuse_unsupported_platform() -> None:
|
|
91
|
+
"""Exit with one clean line on native Windows unless the user opted in."""
|
|
92
|
+
if sys.platform != "win32":
|
|
93
|
+
return
|
|
94
|
+
if os.environ.get(_SKIP_PLATFORM_CHECK_ENV, "").strip().lower() in {"1", "true", "yes"}:
|
|
95
|
+
return
|
|
96
|
+
raise SystemExit(f"Error: {_UNSUPPORTED_PLATFORM_MESSAGE}")
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _is_utf8(stream) -> bool:
|
|
100
|
+
encoding = getattr(stream, "encoding", None) or ""
|
|
101
|
+
return encoding.lower().replace("-", "").replace("_", "") == "utf8"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _use_utf8_output() -> None:
|
|
105
|
+
"""Use UTF-8 for redirected output and piped input on native Windows.
|
|
106
|
+
|
|
107
|
+
Redirected stdout/stderr and piped stdin use the ANSI code page there (for
|
|
108
|
+
example cp1252), which cannot encode the CLI's check marks and arrows and
|
|
109
|
+
would crash the command midway. Consoles already use UTF-8, so this changes
|
|
110
|
+
nothing there.
|
|
111
|
+
"""
|
|
112
|
+
if sys.platform != "win32":
|
|
113
|
+
return
|
|
114
|
+
for stream in (sys.stdout, sys.stderr):
|
|
115
|
+
if stream is not None and not _is_utf8(stream) and hasattr(stream, "reconfigure"):
|
|
116
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
117
|
+
stdin = sys.stdin
|
|
118
|
+
try:
|
|
119
|
+
piped = stdin is not None and not stdin.isatty()
|
|
120
|
+
except (OSError, ValueError):
|
|
121
|
+
piped = False
|
|
122
|
+
if piped and not _is_utf8(stdin) and hasattr(stdin, "reconfigure"):
|
|
123
|
+
stdin.reconfigure(encoding="utf-8", errors="replace")
|
|
124
|
+
|
|
125
|
+
|
|
75
126
|
def main():
|
|
76
127
|
"""Entry point for console_scripts: nooa <subcommand>."""
|
|
128
|
+
_refuse_unsupported_platform()
|
|
129
|
+
_use_utf8_output()
|
|
77
130
|
oo()
|
|
@@ -0,0 +1,43 @@
|
|
|
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 importlib import import_module
|
|
6
|
+
|
|
7
|
+
_EXPORT_MODULES = {
|
|
8
|
+
"ActivityShellTools": "activity",
|
|
9
|
+
"FileEdit": "activity",
|
|
10
|
+
"TerminalCommandFinished": "activity",
|
|
11
|
+
"TerminalCommandOutput": "activity",
|
|
12
|
+
"TerminalCommandStarted": "activity",
|
|
13
|
+
"CodingAgent": "agent",
|
|
14
|
+
"discover_agent_instruction_files": "instructions",
|
|
15
|
+
"render_agent_instructions": "instructions",
|
|
16
|
+
"load_coding_skills_dirs": "settings",
|
|
17
|
+
"CodingSlashCommand": "slash_commands",
|
|
18
|
+
"CodingSlashCommandRegistry": "slash_commands",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def __getattr__(name: str):
|
|
23
|
+
"""Load host components only when requested, not for leaf utility imports."""
|
|
24
|
+
if name not in _EXPORT_MODULES:
|
|
25
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
26
|
+
value = getattr(import_module(f"{__name__}.{_EXPORT_MODULES[name]}"), name)
|
|
27
|
+
globals()[name] = value
|
|
28
|
+
return value
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
__all__ = [
|
|
32
|
+
"ActivityShellTools",
|
|
33
|
+
"CodingAgent",
|
|
34
|
+
"CodingSlashCommand",
|
|
35
|
+
"CodingSlashCommandRegistry",
|
|
36
|
+
"FileEdit",
|
|
37
|
+
"TerminalCommandFinished",
|
|
38
|
+
"TerminalCommandOutput",
|
|
39
|
+
"TerminalCommandStarted",
|
|
40
|
+
"discover_agent_instruction_files",
|
|
41
|
+
"load_coding_skills_dirs",
|
|
42
|
+
"render_agent_instructions",
|
|
43
|
+
]
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
|
|
5
5
|
from __future__ import annotations
|
|
6
6
|
|
|
7
|
+
import asyncio
|
|
7
8
|
import logging
|
|
9
|
+
import re
|
|
8
10
|
from collections.abc import AsyncIterator
|
|
9
11
|
from difflib import unified_diff
|
|
10
12
|
from functools import wraps
|
|
@@ -27,6 +29,8 @@ logger = logging.getLogger(__name__)
|
|
|
27
29
|
|
|
28
30
|
_MAX_EVENT_TEXT_CHARS = 10_000
|
|
29
31
|
_MAX_COMMAND_OUTPUT_CHARS = 30_000
|
|
32
|
+
_MAX_DIFF_INPUT_CHARS = 1_000_000
|
|
33
|
+
_MAX_DIFF_INPUT_LINES = 20_000
|
|
30
34
|
|
|
31
35
|
|
|
32
36
|
def _line_count(value: str) -> int:
|
|
@@ -35,23 +39,74 @@ def _line_count(value: str) -> int:
|
|
|
35
39
|
return value.count("\n") + (0 if value.endswith("\n") else 1)
|
|
36
40
|
|
|
37
41
|
|
|
38
|
-
def
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
def _diff_input_is_too_large(value: str) -> bool:
|
|
43
|
+
return len(value) > _MAX_DIFF_INPUT_CHARS or _line_count(value) > _MAX_DIFF_INPUT_LINES
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _omitted_diff(path: str, reason: str) -> tuple[str, bool]:
|
|
47
|
+
return (
|
|
48
|
+
f"--- a/{path}\n+++ b/{path}\n@@ -0,0 +0,0 @@\n Diff omitted: {reason}.\n",
|
|
49
|
+
False,
|
|
46
50
|
)
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
_HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _offset_hunk_header(line: str, offset: int) -> str:
|
|
57
|
+
"""Shift one hunk header into file coordinates, keeping difflib's counts."""
|
|
58
|
+
match = _HUNK_HEADER.match(line.rstrip("\n"))
|
|
59
|
+
if match is None:
|
|
60
|
+
return line
|
|
61
|
+
old_start, old_count, new_start, new_count, trailer = match.groups()
|
|
62
|
+
old_part = f"-{int(old_start) + offset}" + (f",{old_count}" if old_count else "")
|
|
63
|
+
new_part = f"+{int(new_start) + offset}" + (f",{new_count}" if new_count else "")
|
|
64
|
+
return f"@@ {old_part} {new_part} @@{trailer}\n"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _edit_diff(
|
|
68
|
+
path: str,
|
|
69
|
+
old_text: str,
|
|
70
|
+
new_text: str,
|
|
71
|
+
start_line: int | None,
|
|
72
|
+
*,
|
|
73
|
+
whole_file: bool = False,
|
|
74
|
+
) -> tuple[str, bool]:
|
|
75
|
+
"""Return a bounded, line-oriented unified diff and its completeness.
|
|
76
|
+
|
|
77
|
+
``whole_file`` marks both texts as complete file contents, where a missing
|
|
78
|
+
final newline is a real file state worth reporting. Fragment diffs stop
|
|
79
|
+
mid-file, so the EOF marker there was noise.
|
|
80
|
+
"""
|
|
81
|
+
if _diff_input_is_too_large(old_text) or _diff_input_is_too_large(new_text):
|
|
82
|
+
return _omitted_diff(path, "file content exceeds the safe diff preview limit")
|
|
83
|
+
|
|
84
|
+
output = TruncatingStringIO(limit=_MAX_EVENT_TEXT_CHARS)
|
|
85
|
+
# A region diff is generated in region coordinates; every hunk shifts by the
|
|
86
|
+
# same amount to reach file coordinates. Rewriting only the first one, with
|
|
87
|
+
# the whole region's counts, left later hunks region-relative — able to point
|
|
88
|
+
# before the first hunk, and unappliable by patch.
|
|
89
|
+
offset = start_line - 1 if start_line is not None else 0
|
|
90
|
+
for line in unified_diff(
|
|
91
|
+
old_text.splitlines(keepends=True),
|
|
92
|
+
new_text.splitlines(keepends=True),
|
|
93
|
+
fromfile=f"a/{path}",
|
|
94
|
+
tofile=f"b/{path}",
|
|
95
|
+
):
|
|
96
|
+
if offset and line.startswith("@@ "):
|
|
97
|
+
line = _offset_hunk_header(line, offset)
|
|
98
|
+
if not line.endswith("\n"):
|
|
99
|
+
# difflib emits the source line verbatim, so unterminated content
|
|
100
|
+
# would run the next marker onto the same line ("-a+b").
|
|
101
|
+
if whole_file:
|
|
102
|
+
line = f"{line}\n\\n"
|
|
103
|
+
else:
|
|
104
|
+
# A fragment routinely stops before the file's final
|
|
105
|
+
# newline; marking that as "no newline at end of file"
|
|
106
|
+
# reported a file state that does not exist.
|
|
107
|
+
line = f"{line}\n"
|
|
108
|
+
output.write(line)
|
|
109
|
+
return output.getvalue(), not output.was_truncated
|
|
55
110
|
|
|
56
111
|
|
|
57
112
|
class FileEdit(EventBase): # type: ignore[misc]
|
|
@@ -82,6 +137,10 @@ class FileEdit(EventBase): # type: ignore[misc]
|
|
|
82
137
|
bool,
|
|
83
138
|
Field(description="Whether old_text and new_text contain the complete affected text"),
|
|
84
139
|
] = True
|
|
140
|
+
diff_complete: Annotated[
|
|
141
|
+
bool,
|
|
142
|
+
Field(description="Whether diff contains the complete unified diff"),
|
|
143
|
+
] = True
|
|
85
144
|
|
|
86
145
|
|
|
87
146
|
class TerminalCommandStarted(EventBase): # type: ignore[misc]
|
|
@@ -107,7 +166,11 @@ class TerminalCommandStarted(EventBase): # type: ignore[misc]
|
|
|
107
166
|
|
|
108
167
|
|
|
109
168
|
class TerminalCommandOutput(EventBase): # type: ignore[misc]
|
|
110
|
-
"""
|
|
169
|
+
"""The output of one coding-agent terminal command.
|
|
170
|
+
|
|
171
|
+
Emitted once, when the command finishes: output is buffered and bounded
|
|
172
|
+
rather than streamed, so hosts receive a single event per command.
|
|
173
|
+
"""
|
|
111
174
|
|
|
112
175
|
_role: ClassVar[Role] = Role.RUNTIME_EVENT
|
|
113
176
|
|
|
@@ -129,6 +192,10 @@ class TerminalCommandFinished(EventBase): # type: ignore[misc]
|
|
|
129
192
|
exit_code: Annotated[int | None, Field(description="Process exit code when available")] = None
|
|
130
193
|
timed_out: Annotated[bool, Field(description="Whether the command timed out")] = False
|
|
131
194
|
error: Annotated[str, Field(description="Failure before an exit code was available")] = ""
|
|
195
|
+
cancelled: Annotated[
|
|
196
|
+
bool,
|
|
197
|
+
Field(description="Whether the command was stopped by cancellation rather than failing"),
|
|
198
|
+
] = False
|
|
132
199
|
output_truncated: Annotated[
|
|
133
200
|
bool,
|
|
134
201
|
Field(description="Whether output was omitted from command activity events"),
|
|
@@ -178,6 +245,12 @@ class ActivityShellTools(Skill):
|
|
|
178
245
|
def _resolve_path(self, path: str) -> Path:
|
|
179
246
|
return self._shell._resolve_path(path)
|
|
180
247
|
|
|
248
|
+
def _diff_path(self, resolved: Path) -> str:
|
|
249
|
+
try:
|
|
250
|
+
return resolved.relative_to(self.cwd).as_posix()
|
|
251
|
+
except ValueError:
|
|
252
|
+
return resolved.as_posix().lstrip("/")
|
|
253
|
+
|
|
181
254
|
def _emit(self, event: EventBase) -> None:
|
|
182
255
|
try:
|
|
183
256
|
self._event_manager.add(event)
|
|
@@ -192,7 +265,9 @@ class ActivityShellTools(Skill):
|
|
|
192
265
|
stdin: Annotated[
|
|
193
266
|
str | None, spec(description="Text piped to stdin (replaces heredocs)")
|
|
194
267
|
] = None,
|
|
195
|
-
timeout: Annotated[
|
|
268
|
+
timeout: Annotated[
|
|
269
|
+
float | None, spec(description="Max seconds; None uses the shell's default_timeout")
|
|
270
|
+
] = None,
|
|
196
271
|
) -> ShellResult:
|
|
197
272
|
command_id = str(uuid4())
|
|
198
273
|
bounded_command = pformat(command, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
@@ -215,10 +290,12 @@ class ActivityShellTools(Skill):
|
|
|
215
290
|
try:
|
|
216
291
|
result = await self._shell.run(command, stdin=stdin, timeout=timeout)
|
|
217
292
|
except BaseException as error:
|
|
293
|
+
cancelled = isinstance(error, asyncio.CancelledError)
|
|
218
294
|
self._emit(
|
|
219
295
|
TerminalCommandFinished(
|
|
220
296
|
command_id=command_id,
|
|
221
|
-
error=str(error) or type(error).__name__,
|
|
297
|
+
error="" if cancelled else (str(error) or type(error).__name__),
|
|
298
|
+
cancelled=cancelled,
|
|
222
299
|
)
|
|
223
300
|
)
|
|
224
301
|
raise
|
|
@@ -251,7 +328,13 @@ class ActivityShellTools(Skill):
|
|
|
251
328
|
async def run_stream(
|
|
252
329
|
self,
|
|
253
330
|
command: Annotated[str, spec(description="Shell command to execute")],
|
|
254
|
-
|
|
331
|
+
*,
|
|
332
|
+
stdin: Annotated[
|
|
333
|
+
str | None, spec(description="Text piped to stdin (replaces heredocs)")
|
|
334
|
+
] = None,
|
|
335
|
+
timeout: Annotated[
|
|
336
|
+
float | None, spec(description="Max seconds; None uses the shell's default_timeout")
|
|
337
|
+
] = None,
|
|
255
338
|
) -> AsyncIterator[StreamEvent | StreamDone]:
|
|
256
339
|
command_id = str(uuid4())
|
|
257
340
|
bounded_command = pformat(command, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
@@ -262,12 +345,18 @@ class ActivityShellTools(Skill):
|
|
|
262
345
|
command=bounded_command,
|
|
263
346
|
working_directory=str(self.cwd),
|
|
264
347
|
command_truncated=command_truncated,
|
|
348
|
+
stdin=(
|
|
349
|
+
pformat(stdin, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
350
|
+
if stdin is not None
|
|
351
|
+
else None
|
|
352
|
+
),
|
|
353
|
+
stdin_truncated=stdin is not None and len(stdin) > _MAX_EVENT_TEXT_CHARS,
|
|
265
354
|
)
|
|
266
355
|
)
|
|
267
356
|
finished = False
|
|
268
357
|
stdout_buffer = TruncatingStringIO(limit=_MAX_COMMAND_OUTPUT_CHARS // 2)
|
|
269
358
|
stderr_buffer = TruncatingStringIO(limit=_MAX_COMMAND_OUTPUT_CHARS // 2)
|
|
270
|
-
stream = self._shell.run_stream(command, timeout=timeout)
|
|
359
|
+
stream = self._shell.run_stream(command, stdin=stdin, timeout=timeout)
|
|
271
360
|
try:
|
|
272
361
|
async for item in stream:
|
|
273
362
|
if isinstance(item, StreamDone):
|
|
@@ -306,10 +395,12 @@ class ActivityShellTools(Skill):
|
|
|
306
395
|
truncated=output_truncated,
|
|
307
396
|
)
|
|
308
397
|
)
|
|
398
|
+
cancelled = isinstance(error, asyncio.CancelledError)
|
|
309
399
|
self._emit(
|
|
310
400
|
TerminalCommandFinished(
|
|
311
401
|
command_id=command_id,
|
|
312
|
-
error=str(error) or type(error).__name__,
|
|
402
|
+
error="" if cancelled else (str(error) or type(error).__name__),
|
|
403
|
+
cancelled=cancelled,
|
|
313
404
|
output_truncated=output_truncated,
|
|
314
405
|
)
|
|
315
406
|
)
|
|
@@ -320,7 +411,7 @@ class ActivityShellTools(Skill):
|
|
|
320
411
|
@wraps(ShellTools.read)
|
|
321
412
|
async def read(
|
|
322
413
|
self,
|
|
323
|
-
path: Annotated[str, spec(description="File path (relative to cwd)")],
|
|
414
|
+
path: Annotated[str, spec(description="File path (relative to cwd or absolute)")],
|
|
324
415
|
lines: Annotated[
|
|
325
416
|
tuple[int, int] | None,
|
|
326
417
|
spec(description="(start, end) 1-indexed inclusive, or None for whole file"),
|
|
@@ -348,7 +439,9 @@ class ActivityShellTools(Skill):
|
|
|
348
439
|
if not isinstance(path, str):
|
|
349
440
|
return await self._shell.replace(target, old_or_new, new)
|
|
350
441
|
|
|
351
|
-
resolved =
|
|
442
|
+
resolved = (
|
|
443
|
+
Path(target.resolved_path) if isinstance(target, Match) else self._resolve_path(path)
|
|
444
|
+
)
|
|
352
445
|
if isinstance(target, Match):
|
|
353
446
|
old_text = target.text
|
|
354
447
|
start_line = target.start
|
|
@@ -375,6 +468,12 @@ class ActivityShellTools(Skill):
|
|
|
375
468
|
written_text = result.new_text
|
|
376
469
|
bounded_new = pformat(written_text, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
377
470
|
new_truncated = len(written_text) > _MAX_EVENT_TEXT_CHARS
|
|
471
|
+
diff, diff_complete = _edit_diff(
|
|
472
|
+
self._diff_path(resolved),
|
|
473
|
+
old_text,
|
|
474
|
+
written_text,
|
|
475
|
+
start_line,
|
|
476
|
+
)
|
|
378
477
|
self._emit(
|
|
379
478
|
FileEdit(
|
|
380
479
|
path=str(resolved),
|
|
@@ -383,8 +482,9 @@ class ActivityShellTools(Skill):
|
|
|
383
482
|
new_text=bounded_new,
|
|
384
483
|
start_line=start_line,
|
|
385
484
|
end_line=end_line,
|
|
386
|
-
diff=
|
|
485
|
+
diff=diff,
|
|
387
486
|
content_complete=not old_truncated and not new_truncated,
|
|
487
|
+
diff_complete=diff_complete,
|
|
388
488
|
)
|
|
389
489
|
)
|
|
390
490
|
return result
|
|
@@ -392,40 +492,68 @@ class ActivityShellTools(Skill):
|
|
|
392
492
|
@wraps(ShellTools.write_file)
|
|
393
493
|
async def write_file(
|
|
394
494
|
self,
|
|
395
|
-
path: Annotated[str, spec(description="File path (relative to cwd)")],
|
|
495
|
+
path: Annotated[str, spec(description="File path (relative to cwd or absolute)")],
|
|
396
496
|
content: Annotated[str, spec(description="Full file content")],
|
|
397
497
|
) -> FileWrite:
|
|
398
498
|
resolved = self._resolve_path(path)
|
|
399
499
|
existed = resolved.exists()
|
|
400
|
-
|
|
401
|
-
|
|
500
|
+
old_diff_text: str | None = None
|
|
501
|
+
old_content_complete = not existed
|
|
402
502
|
if existed:
|
|
403
503
|
try:
|
|
404
504
|
with resolved.open("r") as stream:
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
old_text = pformat(old_text, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
505
|
+
old_diff_text = stream.read(_MAX_DIFF_INPUT_CHARS + 1)
|
|
506
|
+
old_content_complete = len(old_diff_text) <= _MAX_EVENT_TEXT_CHARS
|
|
408
507
|
except (OSError, UnicodeError):
|
|
409
508
|
# Observation must not make an otherwise valid overwrite fail.
|
|
410
|
-
|
|
509
|
+
old_content_complete = False
|
|
411
510
|
|
|
412
511
|
result = await self._shell.write_file(path, content)
|
|
512
|
+
bounded_old = (
|
|
513
|
+
pformat(
|
|
514
|
+
old_diff_text,
|
|
515
|
+
max_string=_MAX_EVENT_TEXT_CHARS,
|
|
516
|
+
unquote_strings=True,
|
|
517
|
+
)
|
|
518
|
+
if old_diff_text is not None
|
|
519
|
+
else None
|
|
520
|
+
)
|
|
413
521
|
bounded_content = pformat(content, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
414
522
|
content_truncated = len(content) > _MAX_EVENT_TEXT_CHARS
|
|
523
|
+
if existed and old_diff_text is None:
|
|
524
|
+
diff, diff_complete = _omitted_diff(
|
|
525
|
+
self._diff_path(resolved),
|
|
526
|
+
"previous file content could not be read",
|
|
527
|
+
)
|
|
528
|
+
else:
|
|
529
|
+
diff, diff_complete = _edit_diff(
|
|
530
|
+
self._diff_path(resolved),
|
|
531
|
+
old_diff_text or "",
|
|
532
|
+
content,
|
|
533
|
+
None,
|
|
534
|
+
whole_file=True,
|
|
535
|
+
)
|
|
415
536
|
self._emit(
|
|
416
537
|
FileEdit(
|
|
417
538
|
path=str(resolved),
|
|
418
539
|
operation="update" if existed else "create",
|
|
419
|
-
old_text=
|
|
540
|
+
old_text=bounded_old,
|
|
420
541
|
new_text=bounded_content,
|
|
421
|
-
|
|
542
|
+
# An existing *empty* file has no original line to point at, so
|
|
543
|
+
# reporting 1..1 names lines that never existed.
|
|
544
|
+
start_line=(
|
|
545
|
+
1
|
|
546
|
+
if existed and old_content_complete and _line_count(old_diff_text or "")
|
|
547
|
+
else None
|
|
548
|
+
),
|
|
422
549
|
end_line=(
|
|
423
|
-
|
|
424
|
-
if existed and
|
|
550
|
+
_line_count(old_diff_text or "")
|
|
551
|
+
if existed and old_content_complete and _line_count(old_diff_text or "")
|
|
425
552
|
else None
|
|
426
553
|
),
|
|
427
|
-
diff=
|
|
428
|
-
content_complete=
|
|
554
|
+
diff=diff,
|
|
555
|
+
content_complete=old_content_complete and not content_truncated,
|
|
556
|
+
diff_complete=diff_complete,
|
|
429
557
|
)
|
|
430
558
|
)
|
|
431
559
|
return result
|