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.
Files changed (50) hide show
  1. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/PKG-INFO +15 -5
  2. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/README.md +13 -3
  3. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/coding/__init__.py +13 -0
  4. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/coding/activity.py +135 -35
  5. nooa_cli-0.0.10/src/nooa_cli/coding/agent.py +193 -0
  6. nooa_cli-0.0.10/src/nooa_cli/coding/instructions.py +80 -0
  7. nooa_cli-0.0.10/src/nooa_cli/coding/settings.py +140 -0
  8. nooa_cli-0.0.10/src/nooa_cli/coding/slash_commands.py +124 -0
  9. nooa_cli-0.0.10/src/nooa_cli/commands/_otlp_helpers.py +387 -0
  10. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/delete_traces.py +11 -6
  11. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/import_harbor.py +11 -2
  12. nooa_cli-0.0.10/src/nooa_cli/commands/import_traces.py +406 -0
  13. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/_tree_sitter_backend.py +21 -10
  14. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/repo_tools.py +13 -2
  15. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/tests/test_cli_smoke.py +18 -0
  16. nooa_cli-0.0.10/tests/test_coding_activity.py +390 -0
  17. nooa_cli-0.0.10/tests/test_coding_agent.py +218 -0
  18. nooa_cli-0.0.10/tests/test_coding_settings.py +229 -0
  19. nooa_cli-0.0.10/tests/test_coding_slash_commands.py +177 -0
  20. nooa_cli-0.0.10/tests/test_import_traces.py +576 -0
  21. nooa_cli-0.0.10/tests/test_tree_sitter_backend.py +85 -0
  22. nooa_cli-0.0.9/src/nooa_cli/commands/_otlp_helpers.py +0 -177
  23. nooa_cli-0.0.9/src/nooa_cli/commands/import_traces.py +0 -197
  24. nooa_cli-0.0.9/tests/test_coding_activity.py +0 -191
  25. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/.gitignore +0 -0
  26. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/docs/activity-introspection-design.md +0 -0
  27. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/pyproject.toml +0 -0
  28. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/AGENTS.md +0 -0
  29. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/__init__.py +0 -0
  30. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/__main__.py +0 -0
  31. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/_common.py +0 -0
  32. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/__init__.py +0 -0
  33. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/_template.py +0 -0
  34. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/config.py +0 -0
  35. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/eval.py +0 -0
  36. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/start_dev.py +0 -0
  37. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/commands/traces.py +0 -0
  38. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/completion.py +0 -0
  39. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/sessions/__init__.py +0 -0
  40. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/sessions/events.py +0 -0
  41. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/sessions/store.py +0 -0
  42. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/__init__.py +0 -0
  43. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/pyp/__init__.py +0 -0
  44. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/pyp/errors.py +0 -0
  45. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/pyp/sources.py +0 -0
  46. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/pyp/stream.py +0 -0
  47. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/pyp/tests/__init__.py +0 -0
  48. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/src/nooa_cli/tools/pyp/tests/test_pyp.py +0 -0
  49. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/tests/test_command_plugins.py +0 -0
  50. {nooa_cli-0.0.9 → nooa_cli-0.0.10}/tests/test_sessions.py +0 -0
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: nooa-cli
3
- Version: 0.0.9
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 # launch the trace viewer
48
- nooa eval ... # eval pipeline runner
49
- nooa traces ... # inspect/manage trace files
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 # launch the trace viewer
21
- nooa eval ... # eval pipeline runner
22
- nooa traces ... # inspect/manage trace files
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 _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
- )
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
- 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)
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
- """One streaming output chunk from a coding-agent terminal command."""
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 = self._resolve_path(path)
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=_edit_diff(path, bounded_old, bounded_new, start_line),
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
- old_text: str | None = None
401
- old_complete = True
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
- 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)
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
- old_complete = False
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=old_text,
512
+ old_text=bounded_old,
420
513
  new_text=bounded_content,
421
- start_line=1 if existed and old_complete and old_text is not None else None,
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
- max(1, _line_count(old_text))
424
- if existed and old_complete and old_text is not None
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=_edit_diff(path, old_text or "", bounded_content, 1),
428
- content_complete=old_complete and not content_truncated,
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