nooa-cli 0.0.9__tar.gz → 0.0.10__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.10}/PKG-INFO +15 -5
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/README.md +13 -3
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/coding/__init__.py +13 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/coding/activity.py +135 -35
- nooa_cli-0.0.10/src/nooa_cli/coding/agent.py +193 -0
- nooa_cli-0.0.10/src/nooa_cli/coding/instructions.py +80 -0
- nooa_cli-0.0.10/src/nooa_cli/coding/settings.py +140 -0
- nooa_cli-0.0.10/src/nooa_cli/coding/slash_commands.py +124 -0
- nooa_cli-0.0.10/src/nooa_cli/commands/_otlp_helpers.py +387 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/delete_traces.py +11 -6
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/import_harbor.py +11 -2
- nooa_cli-0.0.10/src/nooa_cli/commands/import_traces.py +406 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/_tree_sitter_backend.py +21 -10
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/repo_tools.py +13 -2
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/tests/test_cli_smoke.py +18 -0
- nooa_cli-0.0.10/tests/test_coding_activity.py +390 -0
- nooa_cli-0.0.10/tests/test_coding_agent.py +218 -0
- nooa_cli-0.0.10/tests/test_coding_settings.py +229 -0
- nooa_cli-0.0.10/tests/test_coding_slash_commands.py +177 -0
- nooa_cli-0.0.10/tests/test_import_traces.py +576 -0
- nooa_cli-0.0.10/tests/test_tree_sitter_backend.py +85 -0
- 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.10}/.gitignore +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/docs/activity-introspection-design.md +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/pyproject.toml +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/AGENTS.md +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/__init__.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/__main__.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/_common.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/__init__.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/_template.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/config.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/eval.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/start_dev.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/traces.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/completion.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/sessions/__init__.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/sessions/events.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/sessions/store.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/__init__.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/pyp/__init__.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/pyp/errors.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/pyp/sources.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/pyp/stream.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/pyp/tests/__init__.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/pyp/tests/test_pyp.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/tests/test_command_plugins.py +0 -0
- {nooa_cli-0.0.9 → nooa_cli-0.0.10}/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.10
|
|
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
|
|
@@ -44,9 +44,19 @@ uv add "nooa-cli[datascience]"
|
|
|
44
44
|
|
|
45
45
|
```bash
|
|
46
46
|
nooa --help
|
|
47
|
-
nooa start-dev
|
|
48
|
-
nooa eval ...
|
|
49
|
-
nooa traces ...
|
|
47
|
+
nooa start-dev # launch the trace viewer
|
|
48
|
+
nooa eval ... # eval pipeline runner
|
|
49
|
+
nooa traces ... # inspect/manage trace files
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Install the separate `nooa-acp` package to add the `nooa acp` plugin command and
|
|
53
|
+
run the NOOA coding agent from an ACP-compatible client:
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
uv add nooa-acp
|
|
57
|
+
export NOOA_MODEL=nvidia_nim/nvidia/nemotron-3-super-120b-a12b
|
|
58
|
+
export NVIDIA_API_KEY=nvapi-...
|
|
59
|
+
uv run nooa-acp
|
|
50
60
|
```
|
|
51
61
|
|
|
52
62
|
See the main repo [README](https://github.com/NVIDIA-NeMo/labs-OO-Agents/blob/main/README.md) for the framework documentation.
|
|
@@ -17,9 +17,19 @@ uv add "nooa-cli[datascience]"
|
|
|
17
17
|
|
|
18
18
|
```bash
|
|
19
19
|
nooa --help
|
|
20
|
-
nooa start-dev
|
|
21
|
-
nooa eval ...
|
|
22
|
-
nooa traces ...
|
|
20
|
+
nooa start-dev # launch the trace viewer
|
|
21
|
+
nooa eval ... # eval pipeline runner
|
|
22
|
+
nooa traces ... # inspect/manage trace files
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Install the separate `nooa-acp` package to add the `nooa acp` plugin command and
|
|
26
|
+
run the NOOA coding agent from an ACP-compatible client:
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv add nooa-acp
|
|
30
|
+
export NOOA_MODEL=nvidia_nim/nvidia/nemotron-3-super-120b-a12b
|
|
31
|
+
export NVIDIA_API_KEY=nvapi-...
|
|
32
|
+
uv run nooa-acp
|
|
23
33
|
```
|
|
24
34
|
|
|
25
35
|
See the main repo [README](https://github.com/NVIDIA-NeMo/labs-OO-Agents/blob/main/README.md) for the framework documentation.
|
|
@@ -9,11 +9,24 @@ from nooa_cli.coding.activity import (
|
|
|
9
9
|
TerminalCommandOutput,
|
|
10
10
|
TerminalCommandStarted,
|
|
11
11
|
)
|
|
12
|
+
from nooa_cli.coding.agent import CodingAgent
|
|
13
|
+
from nooa_cli.coding.instructions import (
|
|
14
|
+
discover_agent_instruction_files,
|
|
15
|
+
render_agent_instructions,
|
|
16
|
+
)
|
|
17
|
+
from nooa_cli.coding.settings import load_coding_skills_dirs
|
|
18
|
+
from nooa_cli.coding.slash_commands import CodingSlashCommand, CodingSlashCommandRegistry
|
|
12
19
|
|
|
13
20
|
__all__ = [
|
|
14
21
|
"ActivityShellTools",
|
|
22
|
+
"CodingAgent",
|
|
23
|
+
"CodingSlashCommand",
|
|
24
|
+
"CodingSlashCommandRegistry",
|
|
15
25
|
"FileEdit",
|
|
16
26
|
"TerminalCommandFinished",
|
|
17
27
|
"TerminalCommandOutput",
|
|
18
28
|
"TerminalCommandStarted",
|
|
29
|
+
"discover_agent_instruction_files",
|
|
30
|
+
"load_coding_skills_dirs",
|
|
31
|
+
"render_agent_instructions",
|
|
19
32
|
]
|
|
@@ -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,61 @@ 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
|
+
) -> tuple[str, bool]:
|
|
73
|
+
"""Return a bounded, line-oriented unified diff and its completeness."""
|
|
74
|
+
if _diff_input_is_too_large(old_text) or _diff_input_is_too_large(new_text):
|
|
75
|
+
return _omitted_diff(path, "file content exceeds the safe diff preview limit")
|
|
76
|
+
|
|
77
|
+
output = TruncatingStringIO(limit=_MAX_EVENT_TEXT_CHARS)
|
|
78
|
+
# A region diff is generated in region coordinates; every hunk shifts by the
|
|
79
|
+
# same amount to reach file coordinates. Rewriting only the first one, with
|
|
80
|
+
# the whole region's counts, left later hunks region-relative — able to point
|
|
81
|
+
# before the first hunk, and unappliable by patch.
|
|
82
|
+
offset = start_line - 1 if start_line is not None else 0
|
|
83
|
+
for line in unified_diff(
|
|
84
|
+
old_text.splitlines(keepends=True),
|
|
85
|
+
new_text.splitlines(keepends=True),
|
|
86
|
+
fromfile=f"a/{path}",
|
|
87
|
+
tofile=f"b/{path}",
|
|
88
|
+
):
|
|
89
|
+
if offset and line.startswith("@@ "):
|
|
90
|
+
line = _offset_hunk_header(line, offset)
|
|
91
|
+
if not line.endswith("\n"):
|
|
92
|
+
# difflib emits the source line verbatim, so unterminated content
|
|
93
|
+
# would run the next marker onto the same line ("-a+b").
|
|
94
|
+
line = f"{line}\n\\n"
|
|
95
|
+
output.write(line)
|
|
96
|
+
return output.getvalue(), not output.was_truncated
|
|
55
97
|
|
|
56
98
|
|
|
57
99
|
class FileEdit(EventBase): # type: ignore[misc]
|
|
@@ -82,6 +124,10 @@ class FileEdit(EventBase): # type: ignore[misc]
|
|
|
82
124
|
bool,
|
|
83
125
|
Field(description="Whether old_text and new_text contain the complete affected text"),
|
|
84
126
|
] = True
|
|
127
|
+
diff_complete: Annotated[
|
|
128
|
+
bool,
|
|
129
|
+
Field(description="Whether diff contains the complete unified diff"),
|
|
130
|
+
] = True
|
|
85
131
|
|
|
86
132
|
|
|
87
133
|
class TerminalCommandStarted(EventBase): # type: ignore[misc]
|
|
@@ -107,7 +153,11 @@ class TerminalCommandStarted(EventBase): # type: ignore[misc]
|
|
|
107
153
|
|
|
108
154
|
|
|
109
155
|
class TerminalCommandOutput(EventBase): # type: ignore[misc]
|
|
110
|
-
"""
|
|
156
|
+
"""The output of one coding-agent terminal command.
|
|
157
|
+
|
|
158
|
+
Emitted once, when the command finishes: output is buffered and bounded
|
|
159
|
+
rather than streamed, so hosts receive a single event per command.
|
|
160
|
+
"""
|
|
111
161
|
|
|
112
162
|
_role: ClassVar[Role] = Role.RUNTIME_EVENT
|
|
113
163
|
|
|
@@ -129,6 +179,10 @@ class TerminalCommandFinished(EventBase): # type: ignore[misc]
|
|
|
129
179
|
exit_code: Annotated[int | None, Field(description="Process exit code when available")] = None
|
|
130
180
|
timed_out: Annotated[bool, Field(description="Whether the command timed out")] = False
|
|
131
181
|
error: Annotated[str, Field(description="Failure before an exit code was available")] = ""
|
|
182
|
+
cancelled: Annotated[
|
|
183
|
+
bool,
|
|
184
|
+
Field(description="Whether the command was stopped by cancellation rather than failing"),
|
|
185
|
+
] = False
|
|
132
186
|
output_truncated: Annotated[
|
|
133
187
|
bool,
|
|
134
188
|
Field(description="Whether output was omitted from command activity events"),
|
|
@@ -178,6 +232,12 @@ class ActivityShellTools(Skill):
|
|
|
178
232
|
def _resolve_path(self, path: str) -> Path:
|
|
179
233
|
return self._shell._resolve_path(path)
|
|
180
234
|
|
|
235
|
+
def _diff_path(self, resolved: Path) -> str:
|
|
236
|
+
try:
|
|
237
|
+
return resolved.relative_to(self.cwd).as_posix()
|
|
238
|
+
except ValueError:
|
|
239
|
+
return resolved.as_posix().lstrip("/")
|
|
240
|
+
|
|
181
241
|
def _emit(self, event: EventBase) -> None:
|
|
182
242
|
try:
|
|
183
243
|
self._event_manager.add(event)
|
|
@@ -215,10 +275,12 @@ class ActivityShellTools(Skill):
|
|
|
215
275
|
try:
|
|
216
276
|
result = await self._shell.run(command, stdin=stdin, timeout=timeout)
|
|
217
277
|
except BaseException as error:
|
|
278
|
+
cancelled = isinstance(error, asyncio.CancelledError)
|
|
218
279
|
self._emit(
|
|
219
280
|
TerminalCommandFinished(
|
|
220
281
|
command_id=command_id,
|
|
221
|
-
error=str(error) or type(error).__name__,
|
|
282
|
+
error="" if cancelled else (str(error) or type(error).__name__),
|
|
283
|
+
cancelled=cancelled,
|
|
222
284
|
)
|
|
223
285
|
)
|
|
224
286
|
raise
|
|
@@ -306,10 +368,12 @@ class ActivityShellTools(Skill):
|
|
|
306
368
|
truncated=output_truncated,
|
|
307
369
|
)
|
|
308
370
|
)
|
|
371
|
+
cancelled = isinstance(error, asyncio.CancelledError)
|
|
309
372
|
self._emit(
|
|
310
373
|
TerminalCommandFinished(
|
|
311
374
|
command_id=command_id,
|
|
312
|
-
error=str(error) or type(error).__name__,
|
|
375
|
+
error="" if cancelled else (str(error) or type(error).__name__),
|
|
376
|
+
cancelled=cancelled,
|
|
313
377
|
output_truncated=output_truncated,
|
|
314
378
|
)
|
|
315
379
|
)
|
|
@@ -320,7 +384,7 @@ class ActivityShellTools(Skill):
|
|
|
320
384
|
@wraps(ShellTools.read)
|
|
321
385
|
async def read(
|
|
322
386
|
self,
|
|
323
|
-
path: Annotated[str, spec(description="File path (relative to cwd)")],
|
|
387
|
+
path: Annotated[str, spec(description="File path (relative to cwd or absolute)")],
|
|
324
388
|
lines: Annotated[
|
|
325
389
|
tuple[int, int] | None,
|
|
326
390
|
spec(description="(start, end) 1-indexed inclusive, or None for whole file"),
|
|
@@ -348,7 +412,9 @@ class ActivityShellTools(Skill):
|
|
|
348
412
|
if not isinstance(path, str):
|
|
349
413
|
return await self._shell.replace(target, old_or_new, new)
|
|
350
414
|
|
|
351
|
-
resolved =
|
|
415
|
+
resolved = (
|
|
416
|
+
Path(target.resolved_path) if isinstance(target, Match) else self._resolve_path(path)
|
|
417
|
+
)
|
|
352
418
|
if isinstance(target, Match):
|
|
353
419
|
old_text = target.text
|
|
354
420
|
start_line = target.start
|
|
@@ -375,6 +441,12 @@ class ActivityShellTools(Skill):
|
|
|
375
441
|
written_text = result.new_text
|
|
376
442
|
bounded_new = pformat(written_text, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
377
443
|
new_truncated = len(written_text) > _MAX_EVENT_TEXT_CHARS
|
|
444
|
+
diff, diff_complete = _edit_diff(
|
|
445
|
+
self._diff_path(resolved),
|
|
446
|
+
old_text,
|
|
447
|
+
written_text,
|
|
448
|
+
start_line,
|
|
449
|
+
)
|
|
378
450
|
self._emit(
|
|
379
451
|
FileEdit(
|
|
380
452
|
path=str(resolved),
|
|
@@ -383,8 +455,9 @@ class ActivityShellTools(Skill):
|
|
|
383
455
|
new_text=bounded_new,
|
|
384
456
|
start_line=start_line,
|
|
385
457
|
end_line=end_line,
|
|
386
|
-
diff=
|
|
458
|
+
diff=diff,
|
|
387
459
|
content_complete=not old_truncated and not new_truncated,
|
|
460
|
+
diff_complete=diff_complete,
|
|
388
461
|
)
|
|
389
462
|
)
|
|
390
463
|
return result
|
|
@@ -392,40 +465,67 @@ class ActivityShellTools(Skill):
|
|
|
392
465
|
@wraps(ShellTools.write_file)
|
|
393
466
|
async def write_file(
|
|
394
467
|
self,
|
|
395
|
-
path: Annotated[str, spec(description="File path (relative to cwd)")],
|
|
468
|
+
path: Annotated[str, spec(description="File path (relative to cwd or absolute)")],
|
|
396
469
|
content: Annotated[str, spec(description="Full file content")],
|
|
397
470
|
) -> FileWrite:
|
|
398
471
|
resolved = self._resolve_path(path)
|
|
399
472
|
existed = resolved.exists()
|
|
400
|
-
|
|
401
|
-
|
|
473
|
+
old_diff_text: str | None = None
|
|
474
|
+
old_content_complete = not existed
|
|
402
475
|
if existed:
|
|
403
476
|
try:
|
|
404
477
|
with resolved.open("r") as stream:
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
old_text = pformat(old_text, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
478
|
+
old_diff_text = stream.read(_MAX_DIFF_INPUT_CHARS + 1)
|
|
479
|
+
old_content_complete = len(old_diff_text) <= _MAX_EVENT_TEXT_CHARS
|
|
408
480
|
except (OSError, UnicodeError):
|
|
409
481
|
# Observation must not make an otherwise valid overwrite fail.
|
|
410
|
-
|
|
482
|
+
old_content_complete = False
|
|
411
483
|
|
|
412
484
|
result = await self._shell.write_file(path, content)
|
|
485
|
+
bounded_old = (
|
|
486
|
+
pformat(
|
|
487
|
+
old_diff_text,
|
|
488
|
+
max_string=_MAX_EVENT_TEXT_CHARS,
|
|
489
|
+
unquote_strings=True,
|
|
490
|
+
)
|
|
491
|
+
if old_diff_text is not None
|
|
492
|
+
else None
|
|
493
|
+
)
|
|
413
494
|
bounded_content = pformat(content, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
|
|
414
495
|
content_truncated = len(content) > _MAX_EVENT_TEXT_CHARS
|
|
496
|
+
if existed and old_diff_text is None:
|
|
497
|
+
diff, diff_complete = _omitted_diff(
|
|
498
|
+
self._diff_path(resolved),
|
|
499
|
+
"previous file content could not be read",
|
|
500
|
+
)
|
|
501
|
+
else:
|
|
502
|
+
diff, diff_complete = _edit_diff(
|
|
503
|
+
self._diff_path(resolved),
|
|
504
|
+
old_diff_text or "",
|
|
505
|
+
content,
|
|
506
|
+
None,
|
|
507
|
+
)
|
|
415
508
|
self._emit(
|
|
416
509
|
FileEdit(
|
|
417
510
|
path=str(resolved),
|
|
418
511
|
operation="update" if existed else "create",
|
|
419
|
-
old_text=
|
|
512
|
+
old_text=bounded_old,
|
|
420
513
|
new_text=bounded_content,
|
|
421
|
-
|
|
514
|
+
# An existing *empty* file has no original line to point at, so
|
|
515
|
+
# reporting 1..1 names lines that never existed.
|
|
516
|
+
start_line=(
|
|
517
|
+
1
|
|
518
|
+
if existed and old_content_complete and _line_count(old_diff_text or "")
|
|
519
|
+
else None
|
|
520
|
+
),
|
|
422
521
|
end_line=(
|
|
423
|
-
|
|
424
|
-
if existed and
|
|
522
|
+
_line_count(old_diff_text or "")
|
|
523
|
+
if existed and old_content_complete and _line_count(old_diff_text or "")
|
|
425
524
|
else None
|
|
426
525
|
),
|
|
427
|
-
diff=
|
|
428
|
-
content_complete=
|
|
526
|
+
diff=diff,
|
|
527
|
+
content_complete=old_content_complete and not content_truncated,
|
|
528
|
+
diff_complete=diff_complete,
|
|
429
529
|
)
|
|
430
530
|
)
|
|
431
531
|
return result
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Host-neutral interactive coding agent used by terminal and ACP hosts."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import TYPE_CHECKING, Annotated, Any
|
|
9
|
+
|
|
10
|
+
from nooa import Context, hidden, strategy
|
|
11
|
+
from nooa.agentdoc import doc, spec
|
|
12
|
+
from nooa.config import CodeActConfig, PredictConfig
|
|
13
|
+
from nooa.interactive import (
|
|
14
|
+
InteractiveAgent,
|
|
15
|
+
RespondReason,
|
|
16
|
+
RespondResult,
|
|
17
|
+
SummarizationConfig,
|
|
18
|
+
install_summarizer,
|
|
19
|
+
)
|
|
20
|
+
from nooa.paths import get_project_dir
|
|
21
|
+
from nooa.skill_registry import SkillRegistry
|
|
22
|
+
from nooa.storage.markers import nosnapshot
|
|
23
|
+
from nooa.strategies import CodeActStrategy, PredictStrategy
|
|
24
|
+
from nooa.tools import SkillWriting, TodoManager
|
|
25
|
+
from nooa.tools.shell_tools import ShellTools
|
|
26
|
+
from nooa_cli.coding.activity import ActivityShellTools
|
|
27
|
+
from nooa_cli.coding.instructions import render_agent_instructions
|
|
28
|
+
from nooa_cli.tools.repo_tools import RepoTools
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
from nooa.runtime.channels import Channel
|
|
32
|
+
from nooa.unifiedllm import UnifiedLLM
|
|
33
|
+
|
|
34
|
+
__all__ = ["CodingAgent", "RespondReason"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CodingAgent(InteractiveAgent):
|
|
38
|
+
"""A careful software-development agent working in one local repository.
|
|
39
|
+
|
|
40
|
+
Inspect repository instructions and relevant code before editing. Preserve
|
|
41
|
+
unrelated worktree changes. Use the shell for files and commands, the repo
|
|
42
|
+
tools for definitions and references, and todos for multi-step work.
|
|
43
|
+
|
|
44
|
+
Complete and verify the requested work before returning ``DONE``. Send each
|
|
45
|
+
user-facing answer or question through ``self.message()`` as a complete
|
|
46
|
+
Markdown document. Return ``NEED_INPUT`` only when human input is required,
|
|
47
|
+
and ``WAIT`` only while an actual background job is active.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
# Attributes carrying this agent's own tools. SkillRegistry refuses to let
|
|
51
|
+
# a later skill — a workspace SKILL.md, a client-forwarded MCP server —
|
|
52
|
+
# take one over, which would remove the tool while the model is still told
|
|
53
|
+
# it has it.
|
|
54
|
+
__protected_skill_attrs__ = frozenset({"shell", "repo", "todo", "libs", "skills"})
|
|
55
|
+
|
|
56
|
+
cwd: Annotated[Path, nosnapshot]
|
|
57
|
+
# Host-driven input channels. These live here rather than on
|
|
58
|
+
# InteractiveAgent because they are coding-host concepts: slash commands
|
|
59
|
+
# are a UI affordance whose registry is in this package, and
|
|
60
|
+
# system_messages carries host continuations such as keep-going.
|
|
61
|
+
_slash_commands_in: Annotated[Channel, hidden, nosnapshot]
|
|
62
|
+
slash_commands: Annotated[Any, nosnapshot]
|
|
63
|
+
_system_messages_in: Annotated[Channel, hidden, nosnapshot]
|
|
64
|
+
system_messages: Annotated[Any, nosnapshot]
|
|
65
|
+
shell: Annotated[ActivityShellTools, nosnapshot]
|
|
66
|
+
repo: Annotated[RepoTools, nosnapshot]
|
|
67
|
+
todo: TodoManager
|
|
68
|
+
libs: Annotated[SkillWriting, nosnapshot]
|
|
69
|
+
skills: Annotated[SkillRegistry, nosnapshot]
|
|
70
|
+
_base_shell: Annotated[ShellTools, hidden, nosnapshot]
|
|
71
|
+
_summarizers: Annotated[list[Any], hidden, nosnapshot]
|
|
72
|
+
|
|
73
|
+
def __init__(
|
|
74
|
+
self,
|
|
75
|
+
llm: UnifiedLLM | None = None,
|
|
76
|
+
*,
|
|
77
|
+
cwd: str | Path = ".",
|
|
78
|
+
summarization: SummarizationConfig | None = None,
|
|
79
|
+
skills_dirs: list[Path] | None = None,
|
|
80
|
+
libs_dir: Path | None = None,
|
|
81
|
+
**kwargs: Any,
|
|
82
|
+
) -> None:
|
|
83
|
+
super().__init__(llm=llm, **kwargs)
|
|
84
|
+
self._slash_commands_in = self.queue_manager.queue("slash_commands")
|
|
85
|
+
self.slash_commands = self._slash_commands_in.reader
|
|
86
|
+
self._system_messages_in = self.queue_manager.queue("system_messages")
|
|
87
|
+
self.system_messages = self._system_messages_in.reader
|
|
88
|
+
self.cwd = Path(cwd).resolve()
|
|
89
|
+
self._base_shell = ShellTools(cwd=str(self.cwd))
|
|
90
|
+
self.shell = ActivityShellTools(self._base_shell, self.event_manager)
|
|
91
|
+
self.repo = RepoTools(root=self.cwd, session=self.shell.session)
|
|
92
|
+
self.todo = TodoManager()
|
|
93
|
+
# Libraries live at <project>/.nooa/libs. get_project_dir() resolves
|
|
94
|
+
# that per process, which is what a one-workspace host like the TUI
|
|
95
|
+
# wants. A host serving several workspaces at once must say which
|
|
96
|
+
# project it means, or every session shares one directory — and
|
|
97
|
+
# SkillWriting puts it on sys.path and activates local.*, so that
|
|
98
|
+
# would expose one workspace's agent-authored code to another.
|
|
99
|
+
self.libs = SkillWriting(self, path=libs_dir or get_project_dir("libs"))
|
|
100
|
+
|
|
101
|
+
self.skills = SkillRegistry(self)
|
|
102
|
+
self.skills.register("nemo.shell", self.shell)
|
|
103
|
+
self.skills.register("nemo.repo", self.repo)
|
|
104
|
+
self.skills.register("nemo.todo", self.todo)
|
|
105
|
+
self.skills.register("nemo.libwriting", self.libs)
|
|
106
|
+
self.skills.activate(["nemo.shell", "nemo.repo", "nemo.todo", "nemo.libwriting"])
|
|
107
|
+
# Installed ``nooa.skills`` entry points are part of the shared host
|
|
108
|
+
# surface. Load them so hosts can expose ``@slash_command`` methods,
|
|
109
|
+
# but leave them inactive until the user opts in with ``/skills``.
|
|
110
|
+
# Memory is host-configured because its scope, store and owner are
|
|
111
|
+
# session-specific; loading its default entry point would attach it
|
|
112
|
+
# even when the host has memory disabled.
|
|
113
|
+
loaded = set(self.skills.loaded())
|
|
114
|
+
installed = []
|
|
115
|
+
for name in self.skills.discovered():
|
|
116
|
+
attr_name = name.rsplit(".", 1)[-1].replace("-", "_")
|
|
117
|
+
if name == "nemo.memory" or name in loaded or hasattr(self, attr_name):
|
|
118
|
+
continue
|
|
119
|
+
installed.append(name)
|
|
120
|
+
if installed:
|
|
121
|
+
self.skills.load(installed)
|
|
122
|
+
if skills_dirs:
|
|
123
|
+
self.skills.discover_skills_dirs(skills_dirs)
|
|
124
|
+
|
|
125
|
+
self.context["python_tools"] = Context(
|
|
126
|
+
doc(RepoTools, ActivityShellTools),
|
|
127
|
+
prefix=True,
|
|
128
|
+
)
|
|
129
|
+
self.context["todo_status"] = Context(expr="self.todo.status()")
|
|
130
|
+
self.context["context_usage"] = Context(
|
|
131
|
+
expr="self.context_stats.format() if self.context_stats else ''"
|
|
132
|
+
)
|
|
133
|
+
instructions = render_agent_instructions(self.cwd)
|
|
134
|
+
if instructions:
|
|
135
|
+
self.context["repository_instructions"] = Context(instructions, prefix=True)
|
|
136
|
+
spec(self, "context", hidden=False)
|
|
137
|
+
spec(self, "events", hidden=False)
|
|
138
|
+
|
|
139
|
+
install_summarizer(summarization or SummarizationConfig(), self)
|
|
140
|
+
|
|
141
|
+
def get_summarization_status(self) -> dict[str, Any]:
|
|
142
|
+
"""Return compact history information for host status displays."""
|
|
143
|
+
tags = self.event_manager.keys()
|
|
144
|
+
summary_tags = [tag for tag in tags if ".." in tag]
|
|
145
|
+
summarizers = getattr(self, "_summarizers", [])
|
|
146
|
+
summarizer = summarizers[0] if summarizers else None
|
|
147
|
+
stats = self.context_stats
|
|
148
|
+
return {
|
|
149
|
+
"active_events": len(tags),
|
|
150
|
+
"summary_count": len(summary_tags),
|
|
151
|
+
"summary_tags": summary_tags,
|
|
152
|
+
"has_summarizer": summarizer is not None,
|
|
153
|
+
"policy": getattr(summarizer, "policy", "none") if summarizer else "none",
|
|
154
|
+
"current_tokens": getattr(stats, "total_tokens", 0) if stats else 0,
|
|
155
|
+
"max_tokens": getattr(summarizer, "max_tokens", 0) if summarizer else 0,
|
|
156
|
+
"preserve_recent": getattr(summarizer, "preserve_recent", 0) if summarizer else 0,
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
@hidden
|
|
160
|
+
@strategy(PredictStrategy(PredictConfig(output_serialization="tool_call")))
|
|
161
|
+
async def name_session(self, user_message: str) -> str:
|
|
162
|
+
"""Generate an ultra-short 2-5 word session title for the conversation
|
|
163
|
+
opened by the given user message."""
|
|
164
|
+
...
|
|
165
|
+
|
|
166
|
+
@hidden
|
|
167
|
+
@strategy(CodeActStrategy(config=CodeActConfig(cell_timeout=1800.0)))
|
|
168
|
+
async def handle(self, notification: dict[str, list[Any]]) -> RespondResult:
|
|
169
|
+
"""Fulfill the newest coding request delivered in ``notification``.
|
|
170
|
+
|
|
171
|
+
Work until the request is complete or genuinely needs user input. Use
|
|
172
|
+
as many small execution cells as necessary and inspect each result
|
|
173
|
+
before proceeding. Never claim a check passed without running it.
|
|
174
|
+
|
|
175
|
+
End with exactly one ``return_result(RespondReason.<reason>,
|
|
176
|
+
explanation="...")``. The explanation must say what completed, what
|
|
177
|
+
input is needed, or which live job is still running.
|
|
178
|
+
"""
|
|
179
|
+
...
|
|
180
|
+
|
|
181
|
+
@hidden
|
|
182
|
+
async def close(self) -> None:
|
|
183
|
+
shell = self.shell
|
|
184
|
+
try:
|
|
185
|
+
await self.skills.aclose()
|
|
186
|
+
finally:
|
|
187
|
+
try:
|
|
188
|
+
await self.queue_manager.shutdown()
|
|
189
|
+
finally:
|
|
190
|
+
try:
|
|
191
|
+
await shell.close()
|
|
192
|
+
finally:
|
|
193
|
+
await self.llm.aclose()
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
"""Repository instruction discovery for interactive coding agents."""
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def discover_agent_instruction_files(working_directory: str | Path) -> tuple[Path, ...]:
|
|
14
|
+
"""Return applicable ``AGENTS.md`` files from repository root to cwd.
|
|
15
|
+
|
|
16
|
+
A file in a deeper directory is appended after its parent instruction file,
|
|
17
|
+
so the resulting context naturally gives the most local instructions the
|
|
18
|
+
final word. Discovery stops at the nearest Git worktree root. Outside a Git
|
|
19
|
+
worktree, only the working directory itself is considered.
|
|
20
|
+
"""
|
|
21
|
+
cwd = Path(working_directory).resolve()
|
|
22
|
+
root = _git_root(cwd)
|
|
23
|
+
directories = [cwd]
|
|
24
|
+
if root is not None:
|
|
25
|
+
distance = len(cwd.relative_to(root).parts)
|
|
26
|
+
directories = list(reversed((cwd, *cwd.parents[:distance])))
|
|
27
|
+
|
|
28
|
+
return tuple(path for directory in directories if (path := directory / "AGENTS.md").is_file())
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
#: Per-file and combined caps on repository instructions. The content is
|
|
32
|
+
#: workspace-controlled and lands in a prefix context block on every turn, so an
|
|
33
|
+
#: unbounded read is a memory and context-window risk at session setup.
|
|
34
|
+
_MAX_INSTRUCTION_FILE_CHARS = 100_000
|
|
35
|
+
_MAX_INSTRUCTION_TOTAL_CHARS = 200_000
|
|
36
|
+
_SECTION_SEPARATOR = "\n\n---\n\n"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def render_agent_instructions(working_directory: str | Path) -> str:
|
|
40
|
+
"""Render applicable repository instructions as one bounded context block.
|
|
41
|
+
|
|
42
|
+
Reads are bounded rather than truncated after the fact: the content is
|
|
43
|
+
workspace-controlled and lands in a prefix context block on every turn, so
|
|
44
|
+
reading a huge file in full before discarding most of it would still cost
|
|
45
|
+
the memory. The budget covers the rendered text — headers, separators and
|
|
46
|
+
truncation markers included — not just the retained file content.
|
|
47
|
+
"""
|
|
48
|
+
sections: list[str] = []
|
|
49
|
+
remaining = _MAX_INSTRUCTION_TOTAL_CHARS
|
|
50
|
+
for path in discover_agent_instruction_files(working_directory):
|
|
51
|
+
if remaining <= 0:
|
|
52
|
+
logger.warning("Skipping repository instructions from %s: total limit reached", path)
|
|
53
|
+
continue
|
|
54
|
+
budget = min(_MAX_INSTRUCTION_FILE_CHARS, remaining)
|
|
55
|
+
try:
|
|
56
|
+
with path.open("r", encoding="utf-8") as stream:
|
|
57
|
+
# One char past the budget is enough to know it was cut.
|
|
58
|
+
content = stream.read(budget + 1)
|
|
59
|
+
except (OSError, UnicodeError):
|
|
60
|
+
continue
|
|
61
|
+
truncated = len(content) > budget
|
|
62
|
+
if truncated:
|
|
63
|
+
content = content[:budget]
|
|
64
|
+
logger.warning("Truncating repository instructions from %s at %d chars", path, budget)
|
|
65
|
+
content = content.strip()
|
|
66
|
+
if not content:
|
|
67
|
+
continue
|
|
68
|
+
if truncated:
|
|
69
|
+
content += "\n\n[... truncated ...]"
|
|
70
|
+
section = f"Instructions from {path}:\n\n{content}"
|
|
71
|
+
remaining -= len(section) + len(_SECTION_SEPARATOR)
|
|
72
|
+
sections.append(section)
|
|
73
|
+
return _SECTION_SEPARATOR.join(sections)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _git_root(cwd: Path) -> Path | None:
|
|
77
|
+
for directory in (cwd, *cwd.parents):
|
|
78
|
+
if (directory / ".git").exists():
|
|
79
|
+
return directory
|
|
80
|
+
return None
|