nooa-cli 0.0.10__tar.gz → 0.0.12__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 (109) hide show
  1. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/PKG-INFO +4 -1
  2. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/README.md +2 -0
  3. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/pyproject.toml +1 -0
  4. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/AGENTS.md +7 -2
  5. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/__init__.py +53 -0
  6. nooa_cli-0.0.12/src/nooa_cli/coding/__init__.py +43 -0
  7. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/coding/activity.py +33 -5
  8. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/coding/agent.py +50 -15
  9. nooa_cli-0.0.12/src/nooa_cli/commands/_connect_advertised_media.py +142 -0
  10. nooa_cli-0.0.12/src/nooa_cli/commands/_connect_input_policy.py +124 -0
  11. nooa_cli-0.0.12/src/nooa_cli/commands/_connect_prompts.py +713 -0
  12. nooa_cli-0.0.12/src/nooa_cli/commands/_connect_registry.py +257 -0
  13. nooa_cli-0.0.12/src/nooa_cli/commands/_connect_stages.py +844 -0
  14. nooa_cli-0.0.12/src/nooa_cli/commands/_connect_view.py +897 -0
  15. nooa_cli-0.0.12/src/nooa_cli/commands/_connect_wizard.py +3912 -0
  16. nooa_cli-0.0.12/src/nooa_cli/commands/connect.py +481 -0
  17. nooa_cli-0.0.12/tests/conftest.py +29 -0
  18. nooa_cli-0.0.12/tests/test_cli_platform_check.py +37 -0
  19. nooa_cli-0.0.12/tests/test_cli_utf8_output.py +57 -0
  20. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/tests/test_coding_activity.py +60 -8
  21. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/tests/test_coding_agent.py +60 -0
  22. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/tests/test_coding_slash_commands.py +36 -0
  23. nooa_cli-0.0.12/tests/test_connect_advertised_media.py +602 -0
  24. nooa_cli-0.0.12/tests/test_connect_baseline_safety.py +240 -0
  25. nooa_cli-0.0.12/tests/test_connect_cache_control.py +272 -0
  26. nooa_cli-0.0.12/tests/test_connect_change_settings.py +321 -0
  27. nooa_cli-0.0.12/tests/test_connect_checkbox_pty.py +193 -0
  28. nooa_cli-0.0.12/tests/test_connect_coderabbit_budget.py +60 -0
  29. nooa_cli-0.0.12/tests/test_connect_command.py +1767 -0
  30. nooa_cli-0.0.12/tests/test_connect_current_verification.py +145 -0
  31. nooa_cli-0.0.12/tests/test_connect_decision_catalogue.py +230 -0
  32. nooa_cli-0.0.12/tests/test_connect_decision_listing.py +471 -0
  33. nooa_cli-0.0.12/tests/test_connect_decision_smoke.py +213 -0
  34. nooa_cli-0.0.12/tests/test_connect_diagnostic.py +118 -0
  35. nooa_cli-0.0.12/tests/test_connect_docs.py +59 -0
  36. nooa_cli-0.0.12/tests/test_connect_docs_contract.py +695 -0
  37. nooa_cli-0.0.12/tests/test_connect_edit.py +307 -0
  38. nooa_cli-0.0.12/tests/test_connect_encrypted_reasoning.py +168 -0
  39. nooa_cli-0.0.12/tests/test_connect_endpoint_scope.py +45 -0
  40. nooa_cli-0.0.12/tests/test_connect_entrypoint.py +86 -0
  41. nooa_cli-0.0.12/tests/test_connect_exact_context.py +215 -0
  42. nooa_cli-0.0.12/tests/test_connect_initial_order.py +469 -0
  43. nooa_cli-0.0.12/tests/test_connect_initial_order_pty.py +161 -0
  44. nooa_cli-0.0.12/tests/test_connect_interface_selection.py +778 -0
  45. nooa_cli-0.0.12/tests/test_connect_listing_provenance.py +340 -0
  46. nooa_cli-0.0.12/tests/test_connect_live_followups.py +134 -0
  47. nooa_cli-0.0.12/tests/test_connect_manual_limits.py +623 -0
  48. nooa_cli-0.0.12/tests/test_connect_media_inputs.py +119 -0
  49. nooa_cli-0.0.12/tests/test_connect_media_onboarding.py +965 -0
  50. nooa_cli-0.0.12/tests/test_connect_media_review.py +571 -0
  51. nooa_cli-0.0.12/tests/test_connect_metadata_first.py +663 -0
  52. nooa_cli-0.0.12/tests/test_connect_model_first.py +450 -0
  53. nooa_cli-0.0.12/tests/test_connect_native_media_policy.py +348 -0
  54. nooa_cli-0.0.12/tests/test_connect_native_profile.py +270 -0
  55. nooa_cli-0.0.12/tests/test_connect_practical_ui.py +525 -0
  56. nooa_cli-0.0.12/tests/test_connect_prompts.py +197 -0
  57. nooa_cli-0.0.12/tests/test_connect_proposal_layout.py +120 -0
  58. nooa_cli-0.0.12/tests/test_connect_real_pty.py +176 -0
  59. nooa_cli-0.0.12/tests/test_connect_reasoning_choice.py +397 -0
  60. nooa_cli-0.0.12/tests/test_connect_reply_budget.py +309 -0
  61. nooa_cli-0.0.12/tests/test_connect_review_followups.py +233 -0
  62. nooa_cli-0.0.12/tests/test_connect_review_readiness.py +115 -0
  63. nooa_cli-0.0.12/tests/test_connect_save_policy.py +769 -0
  64. nooa_cli-0.0.12/tests/test_connect_serial_ux.py +662 -0
  65. nooa_cli-0.0.12/tests/test_connect_server_details.py +768 -0
  66. nooa_cli-0.0.12/tests/test_connect_severin_review.py +83 -0
  67. nooa_cli-0.0.12/tests/test_connect_skill.py +227 -0
  68. nooa_cli-0.0.12/tests/test_connect_stages.py +521 -0
  69. nooa_cli-0.0.12/tests/test_connect_transcripts.py +252 -0
  70. nooa_cli-0.0.12/tests/test_connect_ux.py +443 -0
  71. nooa_cli-0.0.12/tests/test_connect_view.py +1159 -0
  72. nooa_cli-0.0.12/tests/test_connect_windows.py +79 -0
  73. nooa_cli-0.0.10/src/nooa_cli/coding/__init__.py +0 -32
  74. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/.gitignore +0 -0
  75. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/docs/activity-introspection-design.md +0 -0
  76. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/__main__.py +0 -0
  77. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/_common.py +0 -0
  78. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/coding/instructions.py +0 -0
  79. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/coding/settings.py +0 -0
  80. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/coding/slash_commands.py +0 -0
  81. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/commands/__init__.py +0 -0
  82. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/commands/_otlp_helpers.py +0 -0
  83. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/commands/_template.py +0 -0
  84. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/commands/config.py +0 -0
  85. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/commands/delete_traces.py +0 -0
  86. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/commands/eval.py +0 -0
  87. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/commands/import_harbor.py +0 -0
  88. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/commands/import_traces.py +0 -0
  89. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/commands/start_dev.py +0 -0
  90. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/commands/traces.py +0 -0
  91. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/completion.py +0 -0
  92. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/sessions/__init__.py +0 -0
  93. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/sessions/events.py +0 -0
  94. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/sessions/store.py +0 -0
  95. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/tools/__init__.py +0 -0
  96. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/tools/_tree_sitter_backend.py +0 -0
  97. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/tools/pyp/__init__.py +0 -0
  98. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/tools/pyp/errors.py +0 -0
  99. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/tools/pyp/sources.py +0 -0
  100. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/tools/pyp/stream.py +0 -0
  101. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/tools/pyp/tests/__init__.py +0 -0
  102. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/tools/pyp/tests/test_pyp.py +0 -0
  103. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/src/nooa_cli/tools/repo_tools.py +0 -0
  104. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/tests/test_cli_smoke.py +0 -0
  105. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/tests/test_coding_settings.py +0 -0
  106. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/tests/test_command_plugins.py +0 -0
  107. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/tests/test_import_traces.py +0 -0
  108. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/tests/test_sessions.py +0 -0
  109. {nooa_cli-0.0.10 → nooa_cli-0.0.12}/tests/test_tree_sitter_backend.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: nooa-cli
3
- Version: 0.0.10
3
+ Version: 0.0.12
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,6 +41,8 @@ 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
@@ -13,6 +13,8 @@ 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
@@ -14,6 +14,7 @@ dependencies = [
14
14
  "click>=8.1.0",
15
15
  # CLI config files
16
16
  "pyyaml>=6.0",
17
+ "prompt-toolkit>=3.0.52",
17
18
  ]
18
19
 
19
20
  [project.urls]
@@ -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 "$(_NEMO_COMPLETE=zsh_source nemo)" # zsh
190
- eval "$(_NEMO_COMPLETE=bash_source nemo)" # bash
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
+ ]
@@ -69,8 +69,15 @@ def _edit_diff(
69
69
  old_text: str,
70
70
  new_text: str,
71
71
  start_line: int | None,
72
+ *,
73
+ whole_file: bool = False,
72
74
  ) -> tuple[str, bool]:
73
- """Return a bounded, line-oriented unified diff and its completeness."""
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
+ """
74
81
  if _diff_input_is_too_large(old_text) or _diff_input_is_too_large(new_text):
75
82
  return _omitted_diff(path, "file content exceeds the safe diff preview limit")
76
83
 
@@ -91,7 +98,13 @@ def _edit_diff(
91
98
  if not line.endswith("\n"):
92
99
  # difflib emits the source line verbatim, so unterminated content
93
100
  # would run the next marker onto the same line ("-a+b").
94
- line = f"{line}\n\\n"
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"
95
108
  output.write(line)
96
109
  return output.getvalue(), not output.was_truncated
97
110
 
@@ -252,7 +265,9 @@ class ActivityShellTools(Skill):
252
265
  stdin: Annotated[
253
266
  str | None, spec(description="Text piped to stdin (replaces heredocs)")
254
267
  ] = None,
255
- timeout: Annotated[float, spec(description="Max seconds")] = 30.0,
268
+ timeout: Annotated[
269
+ float | None, spec(description="Max seconds; None uses the shell's default_timeout")
270
+ ] = None,
256
271
  ) -> ShellResult:
257
272
  command_id = str(uuid4())
258
273
  bounded_command = pformat(command, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
@@ -313,7 +328,13 @@ class ActivityShellTools(Skill):
313
328
  async def run_stream(
314
329
  self,
315
330
  command: Annotated[str, spec(description="Shell command to execute")],
316
- timeout: Annotated[float, spec(description="Max seconds to wait before timeout")] = 30.0,
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,
317
338
  ) -> AsyncIterator[StreamEvent | StreamDone]:
318
339
  command_id = str(uuid4())
319
340
  bounded_command = pformat(command, max_string=_MAX_EVENT_TEXT_CHARS, unquote_strings=True)
@@ -324,12 +345,18 @@ class ActivityShellTools(Skill):
324
345
  command=bounded_command,
325
346
  working_directory=str(self.cwd),
326
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,
327
354
  )
328
355
  )
329
356
  finished = False
330
357
  stdout_buffer = TruncatingStringIO(limit=_MAX_COMMAND_OUTPUT_CHARS // 2)
331
358
  stderr_buffer = TruncatingStringIO(limit=_MAX_COMMAND_OUTPUT_CHARS // 2)
332
- stream = self._shell.run_stream(command, timeout=timeout)
359
+ stream = self._shell.run_stream(command, stdin=stdin, timeout=timeout)
333
360
  try:
334
361
  async for item in stream:
335
362
  if isinstance(item, StreamDone):
@@ -504,6 +531,7 @@ class ActivityShellTools(Skill):
504
531
  old_diff_text or "",
505
532
  content,
506
533
  None,
534
+ whole_file=True,
507
535
  )
508
536
  self._emit(
509
537
  FileEdit(
@@ -9,12 +9,14 @@ from typing import TYPE_CHECKING, Annotated, Any
9
9
 
10
10
  from nooa import Context, hidden, strategy
11
11
  from nooa.agentdoc import doc, spec
12
+ from nooa.agents import TokenBudgetSummarizer
12
13
  from nooa.config import CodeActConfig, PredictConfig
13
14
  from nooa.interactive import (
15
+ Done,
14
16
  InteractiveAgent,
15
- RespondReason,
16
- RespondResult,
17
+ NeedInput,
17
18
  SummarizationConfig,
19
+ Waiting,
18
20
  install_summarizer,
19
21
  )
20
22
  from nooa.paths import get_project_dir
@@ -31,7 +33,7 @@ if TYPE_CHECKING:
31
33
  from nooa.runtime.channels import Channel
32
34
  from nooa.unifiedllm import UnifiedLLM
33
35
 
34
- __all__ = ["CodingAgent", "RespondReason"]
36
+ __all__ = ["CodingAgent"]
35
37
 
36
38
 
37
39
  class CodingAgent(InteractiveAgent):
@@ -41,10 +43,10 @@ class CodingAgent(InteractiveAgent):
41
43
  unrelated worktree changes. Use the shell for files and commands, the repo
42
44
  tools for definitions and references, and todos for multi-step work.
43
45
 
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.
46
+ Complete and verify the requested work before returning ``Done``. Put the
47
+ reply to the user in ``Done.message`` as a complete Markdown document.
48
+ Return ``NeedInput`` only when human input is required, and ``Waiting``
49
+ only while an actual background job is active.
48
50
  """
49
51
 
50
52
  # Attributes carrying this agent's own tools. SkillRegistry refuses to let
@@ -144,16 +146,41 @@ class CodingAgent(InteractiveAgent):
144
146
  summary_tags = [tag for tag in tags if ".." in tag]
145
147
  summarizers = getattr(self, "_summarizers", [])
146
148
  summarizer = summarizers[0] if summarizers else None
149
+ config = getattr(summarizer, "config", None) if summarizer else None
150
+ pending = getattr(summarizer, "_pending_task", None) if summarizer else None
151
+ automatic = bool(getattr(summarizer, "_automatic_context_budget", False))
152
+ declared_policy = getattr(summarizer, "policy", None) if summarizer else None
153
+ policy = (
154
+ declared_policy
155
+ if isinstance(declared_policy, str) and declared_policy
156
+ else "token_budget"
157
+ if isinstance(summarizer, TokenBudgetSummarizer)
158
+ else "custom"
159
+ if summarizer is not None
160
+ else "none"
161
+ )
147
162
  stats = self.context_stats
148
163
  return {
149
164
  "active_events": len(tags),
150
165
  "summary_count": len(summary_tags),
151
166
  "summary_tags": summary_tags,
152
167
  "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,
168
+ "policy": policy,
169
+ "current_tokens": getattr(stats, "prompt_tokens", 0) if stats else 0,
170
+ "max_tokens": getattr(config, "max_tokens", 0) if config else 0,
171
+ "threshold_fraction": (
172
+ getattr(summarizer, "_automatic_context_budget_percent", None)
173
+ if automatic
174
+ else None
175
+ ),
176
+ "preserve_recent": getattr(config, "preserve_recent", 0) if config else 0,
177
+ "compaction_pending": pending is not None,
178
+ "compaction_ready": bool(
179
+ pending is not None
180
+ and pending.done()
181
+ and getattr(summarizer, "_pending_summary", None)
182
+ and getattr(summarizer, "_pending_range", None)
183
+ ),
157
184
  }
158
185
 
159
186
  @hidden
@@ -165,16 +192,23 @@ class CodingAgent(InteractiveAgent):
165
192
 
166
193
  @hidden
167
194
  @strategy(CodeActStrategy(config=CodeActConfig(cell_timeout=1800.0)))
168
- async def handle(self, notification: dict[str, list[Any]]) -> RespondResult:
195
+ async def handle(self, notification: dict[str, list[Any]]) -> Done | NeedInput | Waiting:
169
196
  """Fulfill the newest coding request delivered in ``notification``.
170
197
 
171
198
  Work until the request is complete or genuinely needs user input. Use
172
199
  as many small execution cells as necessary and inspect each result
173
200
  before proceeding. Never claim a check passed without running it.
174
201
 
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.
202
+ End the turn with exactly one ``return_result(...)`` of one of these:
203
+
204
+ - ``Done(message=..., explanation=...)`` — the request is complete.
205
+ ``message`` is the reply the host shows the user; ``explanation``
206
+ is a short status line. Add ``evidence=[...]`` for checks you ran.
207
+ - ``NeedInput(question=...)`` — you cannot continue without an
208
+ answer. The host shows the question, so do not also send it with
209
+ ``self.message()``. Add ``options=[...]`` for a single choice.
210
+ - ``Waiting(message=..., explanation=..., on=[...])`` — a background
211
+ job is still running; ``on`` names the job or channel.
178
212
  """
179
213
  ...
180
214
 
@@ -182,6 +216,7 @@ class CodingAgent(InteractiveAgent):
182
216
  async def close(self) -> None:
183
217
  shell = self.shell
184
218
  try:
219
+ await self.aclose()
185
220
  await self.skills.aclose()
186
221
  finally:
187
222
  try:
@@ -0,0 +1,142 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Wizard step that shows which media inputs the model is said to accept.
4
+
5
+ Published model information (the model listing, server details or the
6
+ OpenRouter catalogue) may list input types such as images or audio. This step
7
+ shows those as suggestions and lets the user decide: test them, enable them
8
+ without testing, pick them by hand, or turn media off. Listed media is never
9
+ enabled automatically.
10
+ """
11
+
12
+
13
+ def offer_suggestions(state, previous):
14
+ """Show the media inputs listed for the selected model and ask how to use them.
15
+
16
+ ``state`` is the wizard state; ``previous`` is the list of media inputs the
17
+ entry had before (shown as "Current inputs"). Depending on the answer this
18
+ sets ``state.test_media_inputs`` (inputs to test) or ``state.media_inputs``
19
+ (inputs to enable without testing).
20
+
21
+ Returns True if the user made a choice here, and False if no question was
22
+ asked: the choice already came from command-line options, ``--yes`` was
23
+ given, or nothing was suggested.
24
+ """
25
+ from nooa.unifiedllm import connect
26
+
27
+ from . import _connect_prompts as prompts
28
+ from . import _connect_view as view
29
+
30
+ if not state.metadata_displayed:
31
+ from ._connect_wizard import show_model_metadata
32
+
33
+ show_model_metadata(state)
34
+ from ._connect_input_policy import media_route
35
+ from ._connect_wizard import model_media_hints
36
+
37
+ hints = model_media_hints(state)
38
+ report = connect.advertised_media_suggestions(
39
+ hints,
40
+ source=(hints or {}).get(
41
+ "media_source", "published model information" if hints else "unknown"
42
+ ),
43
+ api_style=state.api_style,
44
+ entry=media_route(state),
45
+ decision_image_transport=state.decision_image_transport or "json",
46
+ typesafe_danger_zone_images=state.typesafe_danger_zone_images,
47
+ )
48
+ from ._connect_wizard import scrub_setup
49
+
50
+ view.section("Media setup")
51
+ for note in report["notes"]:
52
+ if note not in {
53
+ "Media support isn't listed. You can test inputs or choose them yourself.",
54
+ "No media is listed.",
55
+ }:
56
+ view.note(scrub_setup(state, note))
57
+ from ._connect_input_policy import available_media
58
+
59
+ supported = available_media(state)
60
+ # Formats this interface can't send are already excluded from the choices.
61
+ explained = [
62
+ (view.media_label(kind) + " test", reason)
63
+ for kind, reason in report["unavailable_checks"].items()
64
+ if kind in supported
65
+ ]
66
+ if explained:
67
+ view.fields(scrub_setup(state, explained))
68
+
69
+ def input_label(kind):
70
+ return {"audio": "Audio clips", "video": "Videos"}.get(kind, view.media_label(kind))
71
+
72
+ suggested_label = ", ".join(input_label(kind) for kind in report["suggested"])
73
+ checkable_label = ", ".join(input_label(kind) for kind in report["checkable"])
74
+ view.fields(
75
+ [
76
+ ("Suggested inputs", suggested_label),
77
+ ("Current inputs", ", ".join(view.media_label(kind) for kind in previous) or "Off"),
78
+ ]
79
+ if suggested_label
80
+ else [("Current inputs", ", ".join(view.media_label(kind) for kind in previous) or "Off")]
81
+ )
82
+ if (
83
+ state.media_inputs is not None
84
+ or state.test_media_inputs is not None
85
+ or state.yes
86
+ or not report["suggested"]
87
+ ):
88
+ return False
89
+ options = []
90
+ can_test = not state.no_probe and state.approval == "all" and bool(report["checkable"])
91
+ all_inputs = (
92
+ ["image"]
93
+ if state.api_style in connect.DECISION_API_STYLES
94
+ else ["image", "audio", "video", "pdf"]
95
+ )
96
+ all_report = connect.advertised_media_suggestions(
97
+ {"id": state.model, "input_modalities": all_inputs},
98
+ api_style=state.api_style,
99
+ entry=media_route(state),
100
+ decision_image_transport=state.decision_image_transport or "json",
101
+ typesafe_danger_zone_images=state.typesafe_danger_zone_images,
102
+ )
103
+ can_test_all = not state.no_probe and state.approval == "all" and bool(all_report["checkable"])
104
+ if can_test:
105
+ options.append(("test", f"Test and enable {checkable_label}"))
106
+ if can_test_all:
107
+ options.append(("test-all", "Test all media inputs"))
108
+ options.extend(
109
+ (
110
+ ("suggested", f"Enable {suggested_label} without testing"),
111
+ ("manual", "Select media inputs manually"),
112
+ ("none", "Turn media inputs off"),
113
+ )
114
+ )
115
+ action = prompts.labelled_choice(
116
+ "Choose how to use the listed media inputs",
117
+ tuple(options),
118
+ default="test" if can_test else "test-all" if can_test_all else "manual",
119
+ )
120
+ if action in {"test", "test-all"}:
121
+ state.test_media_inputs = (
122
+ list(report["checkable"]) if action == "test" else list(all_inputs)
123
+ )
124
+ state.media_inputs = list(previous)
125
+ state.inherit_media_inputs = True
126
+ if action == "test-all":
127
+ supported = available_media(state)
128
+ for kind, reason in all_report["unavailable_checks"].items():
129
+ if kind not in report["unavailable_checks"] and kind in supported:
130
+ view.fields(scrub_setup(state, [(view.media_label(kind) + " test", reason)]))
131
+ elif action == "manual":
132
+ from ._connect_wizard import select_manual_media
133
+
134
+ select_manual_media(
135
+ state,
136
+ previous,
137
+ **({"available": ("image",)} if state.api_style in connect.DECISION_API_STYLES else {}),
138
+ )
139
+ else:
140
+ state.media_inputs = list(report["suggested"]) if action == "suggested" else []
141
+ state.inherit_media_inputs = False
142
+ return True
@@ -0,0 +1,124 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Which media inputs the wizard may offer for the chosen API style.
4
+
5
+ Each API style can send only some media kinds (for example, the Anthropic
6
+ Messages and OpenAI Responses clients send images and PDFs, Chat Completions
7
+ also sends audio and video, decision clients send images). The rules live in
8
+ ``nooa.media_policy``; this module applies them to the wizard state. It limits
9
+ the media picker to the kinds the API style can send, and asks the user to fix
10
+ an entry whose saved media inputs include kinds the API style cannot send.
11
+ """
12
+
13
+ import click
14
+
15
+
16
+ def media_route(state):
17
+ """Return a model entry describing the endpoint and API style chosen so far.
18
+
19
+ The entry is built from the wizard state without any network request. It
20
+ is only used to look up which media kinds that API style can send; it says
21
+ nothing about what the model itself accepts.
22
+ """
23
+ if state.editing is not None:
24
+ # When editing, the existing entry's client and provider settings are
25
+ # kept unchanged, and they decide how requests are sent at runtime.
26
+ # The wizard's api_style label may not match them, so use the entry.
27
+ return state.editing
28
+ style = state.api_style or "chat"
29
+ prefix = "anthropic/" if style == "anthropic" else "openai/"
30
+ return {
31
+ "model_name": prefix + (state.model or ""),
32
+ "api_base": state.endpoint,
33
+ "client_type": "decision"
34
+ if style in {"systemone", "openai"}
35
+ else "responses"
36
+ if style == "responses"
37
+ else "completion",
38
+ }
39
+
40
+
41
+ def available_media(state, *, entry=None):
42
+ """Return the media kinds the API style of ``entry`` (or the wizard state) can send."""
43
+ from nooa.media_policy import entry_input_interface, implemented_input_kinds
44
+
45
+ return implemented_input_kinds(
46
+ entry_input_interface(entry if entry is not None else media_route(state))
47
+ )
48
+
49
+
50
+ def picker_options(state, *, entry=None):
51
+ """Return keyword arguments that limit the media picker to the kinds that can be sent."""
52
+ available = available_media(state, entry=entry)
53
+ # When all four kinds can be sent (Chat Completions), return no arguments so
54
+ # the picker is called exactly as before, with its default list of kinds.
55
+ return {"available": available} if len(available) != 4 else {}
56
+
57
+
58
+ def repair_native_media(state, current, *, entry=None, explicit=False):
59
+ """Make sure the enabled media inputs ``current`` can be sent with this API style.
60
+
61
+ Only the Anthropic Messages and OpenAI Responses styles are checked here.
62
+ If every enabled kind can be sent, the list is returned unchanged. If not,
63
+ the user chooses: drop the kinds that cannot be sent, turn media off, or
64
+ cancel. The chosen list is stored on ``state`` and returned; cancel returns
65
+ None. Nothing is removed without the user's choice, and the saved entry
66
+ being edited is not modified here (only the new entry being built is).
67
+
68
+ With ``explicit`` (the list came from ``--media-inputs``) or ``--yes``,
69
+ there is no one to ask, so a ``click.UsageError`` is raised instead.
70
+ This only concerns enabled inputs; media tests chosen with "Test all" are
71
+ handled separately and may report kinds that were skipped.
72
+ """
73
+ from nooa.media_inputs import validate_media_inputs
74
+ from nooa.media_policy import (
75
+ entry_input_interface,
76
+ implemented_input_kinds,
77
+ unsupported_input_reason,
78
+ )
79
+
80
+ from . import _connect_prompts as prompts
81
+ from . import _connect_view as view
82
+
83
+ enabled = list(validate_media_inputs(current))
84
+ interface = entry_input_interface(entry if entry is not None else media_route(state))
85
+ if interface not in {"anthropic", "responses"}:
86
+ return enabled
87
+ available = implemented_input_kinds(interface)
88
+ unsupported = [kind for kind in enabled if kind not in available]
89
+ if not unsupported:
90
+ return enabled
91
+ reason = unsupported_input_reason(interface, unsupported)
92
+ if explicit or state.yes:
93
+ raise click.UsageError(
94
+ reason + " Use --media-inputs image,pdf or --media-inputs - to repair, or choose Chat."
95
+ )
96
+ view.warning(reason)
97
+ action = prompts.labelled_choice(
98
+ "Repair unsupported media permissions for this interface",
99
+ (
100
+ ("supported", "Remove unsupported inputs and keep image/PDF settings"),
101
+ ("off", "Turn all media inputs off"),
102
+ ("cancel", "Cancel without changing or saving; choose another interface"),
103
+ ),
104
+ default="cancel",
105
+ )
106
+ if action == "cancel":
107
+ return None
108
+ selected = [kind for kind in enabled if kind in available] if action == "supported" else []
109
+ state.media_inputs = selected
110
+ state.inherit_media_inputs = False
111
+ state.guided_media_revised = True
112
+ return selected
113
+
114
+
115
+ def validate_native_declaration(state, *, entry=None):
116
+ """Raise ``click.UsageError`` if ``--media-inputs`` names a kind this API style cannot send.
117
+
118
+ Runs before any request is sent, so an invalid option fails early.
119
+ """
120
+ if state.media_inputs is None:
121
+ return
122
+ from ._connect_prompts import parse_media_inputs
123
+
124
+ repair_native_media(state, parse_media_inputs(state.media_inputs), entry=entry, explicit=True)