nooa-bench 0.0.7__py3-none-any.whl

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_bench/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """Benchmark agent and Harbor runner for the NOOA framework.
4
+
5
+ Minimal reproducibility package for the NOOA tech report: the benchmark-agnostic
6
+ ``BenchAgent`` (SWE-bench Verified, Terminal-Bench 2.0), the ``nemo-harbor``
7
+ container runner, and the trace analyzer used to extract the per-task token
8
+ statistics reported in the paper.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ # Maps --agent-type CLI values to dotted import paths: "module:ClassName"
14
+ AGENT_CLASSES: dict[str, str] = {
15
+ # Unified SWE-bench + Terminal-Bench agent (the tech report's BenchAgent)
16
+ "bench": "nooa_bench.bench_agent:BenchAgent",
17
+ }
18
+
19
+ __all__ = ["AGENT_CLASSES"]
@@ -0,0 +1,256 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """
4
+ Generic benchmark agent for code and system tasks.
5
+
6
+ A single, non-specialized CodeAct agent that works on any task requiring shell
7
+ access inside a container. Not tuned for any particular benchmark -- the same
8
+ agent handles SWE-bench, Terminal-Bench, or any Harbor-compatible task.
9
+
10
+ Core contract:
11
+ - ``self.shell`` for persistent shell access (run/read/replace/write_file)
12
+ - ``self.repo`` for code navigation that returns ShellTools Match anchors
13
+ - ``self.todo`` for optional structured progress tracking
14
+ - Structured return: the agent must declare solution_description, evidence,
15
+ and command_to_verify when finishing -- forcing reflection before return.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ from nooa import hidden as _hidden
21
+
22
+ _agentdoc_hidden_names = {"_hidden"}
23
+
24
+ with _hidden:
25
+ import logging
26
+ import os
27
+ from typing import TYPE_CHECKING, Any
28
+
29
+ from nooa_cli.tools.repo_tools import RepoTools
30
+ from pydantic import BaseModel, Field
31
+
32
+ from nooa import Agent, CodeActStrategy, strategy
33
+ from nooa.agentdoc import doc, spec
34
+ from nooa.config import CodeActConfig
35
+ from nooa.context_blocks import DynamicContext
36
+ from nooa.tools.shell_tools import ShellTools
37
+ from nooa.tools.todo import TodoManager
38
+ from nooa.unifiedllm import FakeLLMClient
39
+
40
+ if TYPE_CHECKING:
41
+ from nooa.unifiedllm import UnifiedLLM
42
+
43
+ _logger = logging.getLogger(__name__)
44
+
45
+ _OPTIONAL_TESTBED_ACTIVATE = (
46
+ "if [ -f /opt/miniconda3/etc/profile.d/conda.sh ]; then "
47
+ "[ -d /opt/harbor/cpython312/bin ] && export PATH=/opt/harbor/cpython312/bin:$PATH; "
48
+ "source /opt/miniconda3/etc/profile.d/conda.sh; "
49
+ "conda env list | awk '{print $1}' | grep -qx testbed && conda activate testbed || true; "
50
+ "fi"
51
+ )
52
+
53
+
54
+ class TaskResult(BaseModel):
55
+ """Structured result the agent must return when finishing a task."""
56
+
57
+ solution_description: str = Field(
58
+ description="What you did and why it solves the problem. Describe root cause and fix."
59
+ )
60
+ evidence: str = Field(
61
+ description=(
62
+ "Concrete evidence that the task is done: what tests passed, "
63
+ "what output was produced, what behavior changed. Not a guess -- "
64
+ "cite the actual shell output you observed."
65
+ )
66
+ )
67
+ command_to_verify: str = Field(
68
+ description="A shell command a verifier can run to confirm correctness (exit 0 on success)."
69
+ )
70
+
71
+
72
+ @_hidden
73
+ def _problem_statement(task_input: dict) -> str:
74
+ """Extract the task text from supported Harbor/benchmark field names."""
75
+ for key in ("user_message", "problem_statement", "task_description"):
76
+ value = task_input.get(key)
77
+ if isinstance(value, str) and value.strip():
78
+ return value.strip()
79
+ raise ValueError(
80
+ "task_input must include a non-empty user_message, problem_statement, or task_description"
81
+ )
82
+
83
+
84
+ class BenchAgent(
85
+ Agent,
86
+ llm=FakeLLMClient(),
87
+ context={
88
+ "context_usage": DynamicContext(expr="self._context_usage_block()"),
89
+ "todo_status": DynamicContext(expr="self.todo.status()"),
90
+ "task": DynamicContext(expr="self.problem_statement"),
91
+ },
92
+ ):
93
+ """Generic agent for code and system tasks in containers.
94
+
95
+ ## Tools
96
+
97
+ ```python
98
+ r = await self.shell.run("command") # persistent shell
99
+ r = await self.shell.read("file.py", lines=(1,50)) # view -> Match
100
+ defs = await self.repo.symbols("src/", query="Handler") # definitions -> Match anchors
101
+ refs = await self.repo.refs("Handler", path="src/") # usages -> Match anchors
102
+ await self.shell.replace(defs[0], new_code) # edit at Match
103
+ await self.shell.write_file("file.py", content) # create/overwrite
104
+ ```
105
+
106
+ ## Workflow
107
+
108
+ 1. **Understand** -- explore the codebase/environment, reproduce the issue
109
+ 2. **Plan** -- write a plan based on todos
110
+ 3. **Implement and verify** -- make the fix and run relevant tests
111
+ 4. **Return** -- ``return_result(TaskResult(...))`` with evidence
112
+
113
+ ## Return format
114
+
115
+ When you are done, you MUST return a ``TaskResult``:
116
+
117
+ ```python
118
+ return_result(TaskResult(
119
+ solution_description="Root cause: missing URL-encoding in auth.py. Fixed with quote_plus().",
120
+ evidence="pytest tests/test_login.py passed (3 passed in 0.4s)",
121
+ command_to_verify="pytest tests/test_login.py -x",
122
+ ))
123
+ ```
124
+
125
+ Use ``self.todo`` to track progress on multi-step tasks.
126
+ Mark todos done as you complete them.
127
+ """
128
+
129
+ shell: ShellTools
130
+ repo: RepoTools
131
+
132
+ def _context_usage_block(self) -> str:
133
+ """Return context-window usage plus a benchmark-agent compaction hint."""
134
+ if not self.context_stats:
135
+ return ""
136
+ return (
137
+ f"{self.context_stats.format()}\n"
138
+ "If event history is taking too much space, summarize older work "
139
+ "and call self.events.collapse(start_tag, end_tag, summary_text) "
140
+ "to replace it with a compact summary while keeping details accessible."
141
+ )
142
+
143
+ def __init__(self, llm: UnifiedLLM | None = None, **kwargs: Any) -> None:
144
+ super().__init__(llm=llm, **kwargs)
145
+ cwd = next((d for d in ("/testbed", "/app") if os.path.isdir(d)), os.getcwd())
146
+ self._install_python_tools(cwd)
147
+ self.todo = TodoManager()
148
+ self._seed_todos()
149
+ self.problem_statement = ""
150
+ # Base Agent hides context/events by default; BenchAgent's context_usage
151
+ # hint references them, so expose both APIs to the LLM here.
152
+ spec(self, "context", hidden=False)
153
+ spec(self, "events", hidden=False)
154
+ from nooa import Context
155
+
156
+ self.context_manager["python_tools"] = Context(doc(RepoTools, ShellTools), prefix=True)
157
+ self.context_manager["todo"] = Context(doc(type(self.todo)), prefix=True)
158
+
159
+ def _install_python_tools(self, cwd: str) -> None:
160
+ """Install shell/repo tools rooted at the same working directory."""
161
+ self.shell = ShellTools(cwd=cwd, init_command=_OPTIONAL_TESTBED_ACTIVATE)
162
+ self.repo = RepoTools(root=cwd, session=self.shell.session)
163
+
164
+ def _seed_todos(self) -> None:
165
+ """Preload the planning todo every benchmark task should start from."""
166
+ self.todo.add("Create a todo-based plan with clear dependencies")
167
+
168
+ async def _run_evaluation(self, task_input: dict) -> dict:
169
+ """Entry point called by the Harbor runner."""
170
+ # Read task fields generically (Harbor adapters vary in field names).
171
+ self.problem_statement = _problem_statement(task_input)
172
+ instructions = task_input.get("system_prompt") or task_input.get("instructions") or ""
173
+ initial_obs = task_input.get("initial_observation") or ""
174
+
175
+ if instructions:
176
+ self.context["instructions"] = instructions
177
+ if initial_obs:
178
+ self.context["initial_observation"] = initial_obs
179
+
180
+ # Reset shell to the task working dir.
181
+ cwd = task_input.get("working_dir")
182
+ if cwd:
183
+ if not os.path.isdir(cwd):
184
+ raise ValueError(f"working_dir does not exist: {cwd!r}")
185
+ else:
186
+ cwd = next((d for d in ("/testbed", "/app") if os.path.isdir(d)), os.getcwd())
187
+ self._install_python_tools(cwd)
188
+ from nooa import Context
189
+
190
+ self.context_manager["todo"] = Context(doc(type(self.todo)), prefix=True)
191
+ self.todo.clear()
192
+ self._seed_todos()
193
+
194
+ try:
195
+ result = await self._solve_task(self.problem_statement)
196
+ if isinstance(result, TaskResult):
197
+ return {
198
+ "response": result.command_to_verify,
199
+ "success": bool(result.solution_description),
200
+ "result": result.model_dump(),
201
+ }
202
+ # Fallback for non-structured returns
203
+ result_str = str(result) if result is not None else ""
204
+ return {"response": result_str, "success": True, "result": result}
205
+ except Exception as e:
206
+ _logger.error("BenchAgent failed: %s", e)
207
+ return {"response": "", "success": False, "error": str(e)}
208
+
209
+ @strategy(
210
+ CodeActStrategy(
211
+ config=CodeActConfig(
212
+ max_iterations=300, max_retries=10, text_only_stop_behavior="synthetic_comment"
213
+ )
214
+ )
215
+ )
216
+ async def _solve_task(self, description: str) -> TaskResult:
217
+ """Solve the task.
218
+
219
+ You are an expert software engineer and system administrator working
220
+ inside a Linux container. Solve the task described below.
221
+
222
+ ## Task
223
+ {description}
224
+
225
+ ## Instructions
226
+ - Use ``await self.shell.run("command")`` to run shell commands.
227
+ - Use ``await self.shell.read("path")`` to view files.
228
+ - Use ``await self.repo.symbols(path, query="...")`` to find definitions.
229
+ - Use ``await self.repo.refs(name, path=".")`` to find usages.
230
+ - Use ``await self.shell.replace(...)`` to edit files or RepoTools matches.
231
+ - Use ``await self.shell.write_file(path, content)`` to create files.
232
+ - Use ``self.todo`` to track progress on multi-step work.
233
+ - You have root access; install packages as needed.
234
+ - Read task instructions carefully -- grading is strict and automated.
235
+
236
+ ## Verification & Return
237
+
238
+ Before finishing, run the relevant tests to confirm your work is correct.
239
+ Then return a structured result explaining WHY you believe the task is done:
240
+
241
+ ```python
242
+ return_result(TaskResult(
243
+ solution_description="The login handler didn't escape special chars in emails. Fixed by adding quote_plus() in auth.py:42.",
244
+ evidence="pytest tests/test_login.py -x passed: 5 passed in 1.2s",
245
+ command_to_verify="pytest tests/test_login.py -x",
246
+ ))
247
+ ```
248
+
249
+ ## Workflow
250
+
251
+ 1. Explore and understand the task/codebase
252
+ 2. Write a plan based on todos
253
+ 3. Implement the solution and run tests to verify
254
+ 4. Return ``TaskResult(...)`` with concrete evidence
255
+ """
256
+ ...
nooa_bench/protocol.py ADDED
@@ -0,0 +1,191 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """
4
+ Core protocol definitions for the task execution infrastructure.
5
+
6
+ This module defines the execution-engine and trace-analysis protocols along with
7
+ the usage-statistics data structures shared across the evaluation runner.
8
+ """
9
+
10
+ from collections.abc import Awaitable, Callable
11
+ from dataclasses import dataclass, field
12
+ from typing import Any, Protocol, TypeVar
13
+
14
+ # ===========================
15
+ # Layer 0/1/2 Protocols
16
+ # ===========================
17
+ # These protocols define the lower-level execution and analysis infrastructure
18
+ # that the task runner builds on.
19
+
20
+ R = TypeVar("R", covariant=True)
21
+
22
+
23
+ @dataclass
24
+ class EngineConfig:
25
+ """Configuration for execution engines (Layer 0).
26
+
27
+ Generic configuration that all execution engines understand.
28
+ Engine-specific configs can extend this.
29
+ """
30
+
31
+ max_concurrent: int = 10
32
+ timeout_seconds: float | None = None
33
+ enable_checkpointing: bool = True
34
+
35
+
36
+ @dataclass
37
+ class TaskState:
38
+ """Checkpoint state for a single task.
39
+
40
+ Used by execution engines to resume from crashes or skip completed tasks.
41
+ """
42
+
43
+ task_id: str
44
+ completed: bool
45
+ result: Any | None = None
46
+ error: str | None = None
47
+ timestamp: float | None = None
48
+
49
+
50
+ class ExecutionEngine(Protocol):
51
+ """Protocol for swappable execution engines (Layer 0).
52
+
53
+ Different implementations provide different execution strategies:
54
+ - AsyncIOEngine: Async I/O with semaphore (for I/O-bound tasks like LLM APIs)
55
+ - MultiprocessEngine: Process pool (for CPU-bound tasks like local models)
56
+ - RayEngine: Distributed execution across cluster (for massive scale)
57
+ - NemoRunEngine: Slurm/cloud batch submission (for HPC clusters)
58
+
59
+ All engines share the same interface, allowing Layer 2 (Runner) to be
60
+ agnostic to the execution strategy.
61
+ """
62
+
63
+ async def run_tasks(
64
+ self,
65
+ task_ids: list[str],
66
+ task_fn: Callable[[str], Awaitable[R]],
67
+ config: EngineConfig,
68
+ checkpoint_state: list[TaskState] | None = None,
69
+ on_task_complete: Callable[[str, R | Exception], None] | None = None,
70
+ ) -> list[R]:
71
+ """Execute tasks using engine-specific strategy.
72
+
73
+ Args:
74
+ task_ids: List of task identifiers
75
+ task_fn: Async function that takes task_id and returns result
76
+ config: Engine configuration
77
+ checkpoint_state: Previous run state for resume (optional)
78
+ on_task_complete: Callback for each completed task (optional)
79
+
80
+ Returns:
81
+ List of results in same order as task_ids
82
+ """
83
+ ...
84
+
85
+
86
+ @dataclass
87
+ class ModelUsageStats:
88
+ """Usage statistics for a single model."""
89
+
90
+ model_name: str
91
+ prompt_tokens: int = 0
92
+ completion_tokens: int = 0
93
+ total_tokens: int = 0
94
+ call_count: int = 0
95
+ latencies_ms: list[float] = field(default_factory=list)
96
+
97
+ @property
98
+ def p95_latency_ms(self) -> float | None:
99
+ """95th percentile latency in milliseconds."""
100
+ if not self.latencies_ms:
101
+ return None
102
+ sorted_latencies = sorted(self.latencies_ms)
103
+ idx = int(len(sorted_latencies) * 0.95)
104
+ return sorted_latencies[idx]
105
+
106
+
107
+ @dataclass
108
+ class TaskUsageStats:
109
+ """Usage statistics extracted from a single task's trace."""
110
+
111
+ task_id: str
112
+ total_runtime_seconds: float
113
+ models_used: list[ModelUsageStats] = field(default_factory=list)
114
+ total_llm_calls: int = 0
115
+
116
+ @property
117
+ def total_tokens(self) -> int:
118
+ """Total tokens across all models."""
119
+ return sum(m.total_tokens for m in self.models_used)
120
+
121
+ @property
122
+ def total_prompt_tokens(self) -> int:
123
+ """Total prompt tokens across all models."""
124
+ return sum(m.prompt_tokens for m in self.models_used)
125
+
126
+ @property
127
+ def total_completion_tokens(self) -> int:
128
+ """Total completion tokens across all models."""
129
+ return sum(m.completion_tokens for m in self.models_used)
130
+
131
+
132
+ @dataclass
133
+ class AggregateUsageStats:
134
+ """Aggregate usage statistics across multiple tasks."""
135
+
136
+ num_tasks: int
137
+ per_task_stats: list[TaskUsageStats] = field(default_factory=list)
138
+
139
+ @property
140
+ def total_tokens(self) -> int:
141
+ """Total tokens across all tasks."""
142
+ return sum(s.total_tokens for s in self.per_task_stats)
143
+
144
+ @property
145
+ def total_runtime_seconds(self) -> float:
146
+ """Total runtime across all tasks."""
147
+ return sum(s.total_runtime_seconds for s in self.per_task_stats)
148
+
149
+ @property
150
+ def models_breakdown(self) -> dict[str, ModelUsageStats]:
151
+ """Aggregate stats per model across all tasks."""
152
+ by_model: dict[str, ModelUsageStats] = {}
153
+
154
+ for task_stats in self.per_task_stats:
155
+ for model_stats in task_stats.models_used:
156
+ if model_stats.model_name not in by_model:
157
+ by_model[model_stats.model_name] = ModelUsageStats(
158
+ model_name=model_stats.model_name
159
+ )
160
+
161
+ agg = by_model[model_stats.model_name]
162
+ agg.prompt_tokens += model_stats.prompt_tokens
163
+ agg.completion_tokens += model_stats.completion_tokens
164
+ agg.total_tokens += model_stats.total_tokens
165
+ agg.call_count += model_stats.call_count
166
+ agg.latencies_ms.extend(model_stats.latencies_ms)
167
+
168
+ return by_model
169
+
170
+
171
+ class TraceAnalyzer(Protocol):
172
+ """Protocol for analyzing OTel traces to extract usage statistics.
173
+
174
+ Implementations read .jsonl trace files and extract:
175
+ - Token counts per model
176
+ - LLM call latencies
177
+ - Total runtime
178
+
179
+ This avoids duplicating trace data in IntermediateSteps.
180
+ """
181
+
182
+ def analyze_trace(self, trace_path: str) -> TaskUsageStats:
183
+ """Analyze a single trace file and extract usage statistics.
184
+
185
+ Args:
186
+ trace_path: Path to .jsonl trace file
187
+
188
+ Returns:
189
+ TaskUsageStats with extracted metrics
190
+ """
191
+ ...
nooa_bench/runner.py ADDED
@@ -0,0 +1,235 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """
4
+ Harbor agent runner for NOOA — executed inside the Harbor container.
5
+
6
+ Harbor invokes this as a CLI process::
7
+
8
+ nemo-harbor \\
9
+ --instruction '...' \\
10
+ --model 'anthropic/claude-opus-4-6' \\
11
+ --agent-type bench
12
+
13
+ The runner is benchmark-agnostic. Its only jobs are:
14
+
15
+ 1. Instantiate the right agent class from ``--agent-type``.
16
+ 2. Call ``agent._run_evaluation({"user_message": instruction})``.
17
+ 3. Write ``result.json`` to ``/logs/agent/`` and answer text to ``/app/answer.txt``.
18
+
19
+ Benchmark-specific logic (system prompts, instruction parsing, data paths) lives
20
+ inside each agent class, not here.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import importlib
27
+ import json
28
+ import logging
29
+ import os
30
+ import sys
31
+ from pathlib import Path
32
+ from typing import Any
33
+
34
+ import click
35
+
36
+ logger = logging.getLogger("nooa_bench.runner")
37
+
38
+ # Harbor container path conventions
39
+ LOGS_DIR = Path("/logs/agent")
40
+ ARTIFACTS_DIR = Path("/logs/artifacts")
41
+ TRACES_DIR = ARTIFACTS_DIR / "traces"
42
+ ANSWER_FILE = Path("/app/answer.txt")
43
+
44
+
45
+ def _setup_logging() -> None:
46
+ handlers: list[logging.Handler] = [logging.StreamHandler(sys.stderr)]
47
+ try:
48
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
49
+ handlers.append(logging.FileHandler(str(LOGS_DIR / "nooa_bench.log")))
50
+ except OSError:
51
+ # Outside a Harbor container /logs may not exist or be writable — stderr only.
52
+ pass
53
+ logging.basicConfig(
54
+ level=logging.INFO,
55
+ format="%(asctime)s [%(name)s] %(levelname)s: %(message)s",
56
+ handlers=handlers,
57
+ )
58
+
59
+
60
+ def _setup_tracing(model: str, agent_type: str) -> None:
61
+ """Enable OTel tracing, publishing live to the viewer if reachable.
62
+
63
+ Detection order:
64
+ 1. Probe ``OTLP_ENDPOINT`` env var, or ``http://localhost:5001`` by default.
65
+ 2. If reachable → stream spans via the journal exporter with ``eval.model``
66
+ and ``eval.agent_type`` injected as resource attributes so the viewer can
67
+ display them without a separate ``import-harbor`` step.
68
+ 3. If unreachable → fall back to JSONL files in the Harbor artifact
69
+ directory (``/logs/artifacts/traces/``), importable later via
70
+ ``nemo-oo import-harbor``.
71
+
72
+ Note: Apptainer containers share the host network namespace, so
73
+ ``localhost:5001`` inside the container resolves to the developer's host.
74
+ For Docker containers set ``OTLP_ENDPOINT=http://host.docker.internal:5001``.
75
+ """
76
+ try:
77
+ from nooa.tracing import (
78
+ enable_tracing,
79
+ probe_otlp_endpoint,
80
+ )
81
+ from nooa.tracing import exporters as nemo_exporters
82
+ except ImportError:
83
+ logger.warning("nooa.tracing not available, no tracing")
84
+ return
85
+
86
+ endpoint = os.environ.get("OTLP_ENDPOINT", "http://localhost:5001/v1/traces")
87
+
88
+ if probe_otlp_endpoint(endpoint):
89
+ logger.info("OTLP endpoint reachable (%s) — streaming traces live", endpoint)
90
+ enable_tracing(
91
+ exporters=[nemo_exporters.journal(endpoint=endpoint)],
92
+ extra_resource_attrs={"eval.model": model, "eval.agent_type": agent_type},
93
+ )
94
+ logger.info("OTel tracing enabled → %s", endpoint)
95
+ else:
96
+ TRACES_DIR.mkdir(parents=True, exist_ok=True)
97
+ enable_tracing(exporters=[nemo_exporters.jsonl(TRACES_DIR)])
98
+ logger.info("OTel tracing enabled → %s (OTLP not reachable)", TRACES_DIR)
99
+
100
+
101
+ def _import_agent_class(agent_type: str) -> type:
102
+ from nooa_bench import AGENT_CLASSES
103
+
104
+ entry = AGENT_CLASSES.get(agent_type)
105
+ if entry is None:
106
+ raise ValueError(
107
+ f"Unknown agent_type: {agent_type!r}. Must be one of: {sorted(AGENT_CLASSES)}"
108
+ )
109
+ module_path, class_name = entry.rsplit(":", 1)
110
+ mod = importlib.import_module(module_path)
111
+ return getattr(mod, class_name)
112
+
113
+
114
+ def _write_result(result: dict[str, Any], model: str, agent_type: str) -> None:
115
+ """Write result metadata to LOGS_DIR/result.json."""
116
+ LOGS_DIR.mkdir(parents=True, exist_ok=True)
117
+ payload = {
118
+ "model": model,
119
+ "agent_type": agent_type,
120
+ "success": result.get("success", False),
121
+ "response": result.get("response", ""),
122
+ "error": result.get("error"),
123
+ "n_input_tokens": result.get("n_input_tokens"),
124
+ "n_output_tokens": result.get("n_output_tokens"),
125
+ }
126
+ out = LOGS_DIR / "result.json"
127
+ out.write_text(json.dumps(payload, indent=2))
128
+ logger.info("Result written → %s", out)
129
+
130
+
131
+ def _write_answer(result: dict[str, Any]) -> None:
132
+ """Write the agent's answer to /app/answer.txt for Harbor's verifier."""
133
+ answer = result.get("answer") or result.get("response", "")
134
+ if not answer:
135
+ logger.warning("No answer to write to %s", ANSWER_FILE)
136
+ return
137
+ try:
138
+ ANSWER_FILE.parent.mkdir(parents=True, exist_ok=True)
139
+ ANSWER_FILE.write_text(str(answer))
140
+ logger.info("Answer written → %s", ANSWER_FILE)
141
+ except OSError as e:
142
+ logger.warning("Could not write answer file %s: %s", ANSWER_FILE, e)
143
+
144
+
145
+ async def _run(
146
+ instruction: str,
147
+ model: str,
148
+ agent_type: str,
149
+ api_base: str | None,
150
+ working_dir: str | None = None,
151
+ ) -> int:
152
+ """Async main: instantiate, wire, run. Returns exit code (0 = success)."""
153
+ from nooa.unifiedllm import get_llm_client
154
+
155
+ # Build LLM client — honour env-var overrides for local vLLM deployments.
156
+ llm_overrides: dict[str, str] = {}
157
+ if api_base:
158
+ llm_overrides["api_base"] = api_base
159
+ elif base_url := os.environ.get("OPENAI_BASE_URL"):
160
+ llm_overrides["api_base"] = base_url
161
+ if api_key := os.environ.get("OPENAI_API_KEY"):
162
+ llm_overrides["api_key"] = api_key
163
+
164
+ llm_client = get_llm_client(model, **llm_overrides)
165
+
166
+ # Instantiate agent.
167
+ AgentClass = _import_agent_class(agent_type)
168
+ agent: Any = AgentClass(llm=llm_client)
169
+
170
+ # All agents share the same interface: {"user_message": instruction}.
171
+ # Benchmark-specific parsing (system prompts, data paths, etc.) happens
172
+ # inside the agent's _run_evaluation method.
173
+ from nooa.runtime.token_usage import get_task_tokens, start_task_tokens
174
+
175
+ logger.info("Running agent %s (model=%s)...", agent_type, model)
176
+ start_task_tokens()
177
+ task_input: dict[str, Any] = {"user_message": instruction}
178
+ if working_dir:
179
+ task_input["working_dir"] = working_dir
180
+ result = await agent._run_evaluation(task_input)
181
+ result.update(get_task_tokens())
182
+ _write_result(result, model, agent_type)
183
+ _write_answer(result)
184
+
185
+ if result.get("success"):
186
+ logger.info("Agent completed successfully.")
187
+ return 0
188
+ else:
189
+ logger.error("Agent reported failure.")
190
+ return 1
191
+
192
+
193
+ @click.command()
194
+ @click.option("--instruction", required=True, help="Task instruction / problem statement")
195
+ @click.option("--model", required=True, help="Model name in litellm format")
196
+ @click.option("--agent-type", default="bench", show_default=True, help="Agent variant to run")
197
+ @click.option("--working-dir", default=None, help="Working directory for the agent shell session")
198
+ @click.option("--api-base", default=None, help="Override API base URL")
199
+ def main(
200
+ instruction: str,
201
+ model: str,
202
+ agent_type: str,
203
+ working_dir: str | None,
204
+ api_base: str | None,
205
+ ) -> None:
206
+ """Run a NOOA agent on a task inside a Harbor container."""
207
+ _setup_logging()
208
+ logger.info("nooa-bench runner starting")
209
+ logger.info(" model: %s", model)
210
+ logger.info(" agent_type: %s", agent_type)
211
+ if api_base:
212
+ logger.info(" api_base: %s", api_base)
213
+
214
+ # Validate agent_type early so we get a clean error before any heavy imports.
215
+ from nooa_bench import AGENT_CLASSES
216
+
217
+ if agent_type not in AGENT_CLASSES:
218
+ logger.error(
219
+ "Unknown agent_type: %r. Must be one of: %s", agent_type, sorted(AGENT_CLASSES)
220
+ )
221
+ sys.exit(1)
222
+
223
+ _setup_tracing(model=model, agent_type=agent_type)
224
+
225
+ try:
226
+ exit_code = asyncio.run(_run(instruction, model, agent_type, api_base, working_dir))
227
+ except Exception as e:
228
+ logger.exception("Runner failed with unhandled exception: %s", e)
229
+ exit_code = 1
230
+
231
+ sys.exit(exit_code)
232
+
233
+
234
+ if __name__ == "__main__":
235
+ main()
@@ -0,0 +1,186 @@
1
+ # SPDX-FileCopyrightText: Copyright (c) 2026, NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """
4
+ Trace analyzer for extracting usage statistics from execution traces.
5
+
6
+ This module reads JSONL trace files produced by the agent framework and
7
+ extracts per-model token counts, LLM call latencies, and total runtime.
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from nooa_bench.protocol import (
16
+ ModelUsageStats,
17
+ TaskUsageStats,
18
+ )
19
+
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ class TraceAnalyzer:
24
+ """
25
+ Analyzes OTel traces to extract usage statistics.
26
+
27
+ Implements the TraceAnalyzer protocol: reads .jsonl trace files and
28
+ extracts token counts per model, LLM call latencies, and total runtime.
29
+ """
30
+
31
+ def analyze_trace(self, trace_path: str) -> TaskUsageStats:
32
+ """
33
+ Analyze a trace file and extract usage statistics.
34
+
35
+ Implements the TraceAnalyzer protocol to extract:
36
+ - Token counts per model
37
+ - LLM call latencies
38
+ - Total runtime
39
+ - Model usage breakdown
40
+
41
+ Args:
42
+ trace_path: Path to .jsonl trace file
43
+
44
+ Returns:
45
+ TaskUsageStats with extracted metrics
46
+ """
47
+ path = Path(trace_path)
48
+ if not path.exists():
49
+ # Return empty stats for missing trace
50
+ return TaskUsageStats(
51
+ task_id=path.stem,
52
+ total_runtime_seconds=0.0,
53
+ models_used=[],
54
+ total_llm_calls=0,
55
+ )
56
+
57
+ # Track stats per model
58
+ model_stats: dict[str, ModelUsageStats] = {}
59
+ trace_start_time: float | None = None
60
+ trace_end_time: float | None = None
61
+ total_llm_calls = 0
62
+
63
+ with open(path) as f:
64
+ for line in f:
65
+ try:
66
+ span = json.loads(line.strip())
67
+
68
+ # Track overall trace timing
69
+ start_time = span.get("start_time_unix_nano", 0) / 1e9
70
+ end_time = span.get("end_time_unix_nano", 0) / 1e9
71
+
72
+ if trace_start_time is None or start_time < trace_start_time:
73
+ trace_start_time = start_time
74
+ if trace_end_time is None or end_time > trace_end_time:
75
+ trace_end_time = end_time
76
+
77
+ # Look for LLM spans
78
+ attrs = span.get("attributes", {})
79
+ span_name = span.get("name", "")
80
+
81
+ # Check if this is an LLM call span
82
+ if self._is_llm_span(span_name, attrs):
83
+ total_llm_calls += 1
84
+
85
+ # Extract model name
86
+ model_name = attrs.get(
87
+ "llm.model", attrs.get("gen_ai.request.model", "unknown")
88
+ )
89
+
90
+ # Initialize model stats if not seen before
91
+ if model_name not in model_stats:
92
+ model_stats[model_name] = ModelUsageStats(model_name=model_name)
93
+
94
+ stats = model_stats[model_name]
95
+
96
+ # Extract token counts with validation
97
+ prompt_tokens = attrs.get(
98
+ "llm.token_count.prompt", attrs.get("gen_ai.usage.input_tokens", 0)
99
+ )
100
+ completion_tokens = attrs.get(
101
+ "llm.token_count.completion", attrs.get("gen_ai.usage.output_tokens", 0)
102
+ )
103
+
104
+ # Validate and parse token counts
105
+ prompt_count = self._parse_token_count(prompt_tokens, "prompt_tokens", path)
106
+ completion_count = self._parse_token_count(
107
+ completion_tokens, "completion_tokens", path
108
+ )
109
+
110
+ stats.prompt_tokens += prompt_count
111
+ stats.completion_tokens += completion_count
112
+ stats.total_tokens += prompt_count + completion_count
113
+ stats.call_count += 1
114
+
115
+ # Calculate latency in milliseconds
116
+ if start_time > 0 and end_time > 0:
117
+ latency_ms = (end_time - start_time) * 1000
118
+ stats.latencies_ms.append(latency_ms)
119
+
120
+ except json.JSONDecodeError:
121
+ continue
122
+
123
+ # Calculate total runtime
124
+ runtime_seconds = 0.0
125
+ if trace_start_time and trace_end_time:
126
+ runtime_seconds = trace_end_time - trace_start_time
127
+
128
+ return TaskUsageStats(
129
+ task_id=path.stem,
130
+ total_runtime_seconds=runtime_seconds,
131
+ models_used=list(model_stats.values()),
132
+ total_llm_calls=total_llm_calls,
133
+ )
134
+
135
+ def _is_llm_span(self, span_name: str, attributes: dict[str, Any]) -> bool:
136
+ """Check if a span represents an LLM API call."""
137
+ # Check span name
138
+ llm_span_names = ["llm", "chat", "completion", "generation", "inference"]
139
+ if any(name in span_name.lower() for name in llm_span_names):
140
+ return True
141
+
142
+ # Check for LLM-related attributes
143
+ llm_attrs = [
144
+ "llm.model",
145
+ "llm.token_count.prompt",
146
+ "llm.token_count.completion",
147
+ "gen_ai.request.model",
148
+ "gen_ai.usage.input_tokens",
149
+ "gen_ai.usage.output_tokens",
150
+ ]
151
+ return any(attr in attributes for attr in llm_attrs)
152
+
153
+ def _parse_token_count(self, value: Any, field_name: str, trace_path: Path) -> int:
154
+ """Parse a token count value with validation.
155
+
156
+ Args:
157
+ value: The raw value from the trace (could be int, str, None, etc.)
158
+ field_name: Name of the field for logging
159
+ trace_path: Path to trace file for logging context
160
+
161
+ Returns:
162
+ Parsed integer token count, or 0 if invalid
163
+ """
164
+ if value is None:
165
+ return 0
166
+
167
+ if isinstance(value, int):
168
+ return value
169
+
170
+ if isinstance(value, float):
171
+ return int(value)
172
+
173
+ if isinstance(value, str):
174
+ try:
175
+ return int(value)
176
+ except ValueError:
177
+ logger.warning(
178
+ f"Invalid {field_name} value '{value}' in trace {trace_path.name}, using 0"
179
+ )
180
+ return 0
181
+
182
+ logger.warning(
183
+ f"Unexpected type {type(value).__name__} for {field_name} in trace "
184
+ f"{trace_path.name}, using 0"
185
+ )
186
+ return 0
@@ -0,0 +1,29 @@
1
+ Metadata-Version: 2.4
2
+ Name: nooa-bench
3
+ Version: 0.0.7
4
+ Summary: Benchmark agent (BenchAgent) and Harbor runner for the NOOA framework — reproduces the tech report's SWE-bench and Terminal-Bench results
5
+ Project-URL: Homepage, https://github.com/NVIDIA-NeMo/labs-OO-Agents
6
+ Project-URL: Repository, https://github.com/NVIDIA-NeMo/labs-OO-Agents
7
+ Project-URL: Issues, https://github.com/NVIDIA-NeMo/labs-OO-Agents/issues
8
+ License: Apache-2.0
9
+ Requires-Python: <3.14,>=3.12
10
+ Requires-Dist: click>=8.1
11
+ Requires-Dist: nooa
12
+ Requires-Dist: nooa-cli
13
+ Description-Content-Type: text/markdown
14
+
15
+ # nooa-bench
16
+
17
+ Benchmark agent (`BenchAgent`) and Harbor runner for
18
+ [NOOA](https://github.com/NVIDIA-NeMo/labs-OO-Agents). Reproduces the SWE-bench
19
+ and Terminal-Bench results from the NOOA tech report.
20
+
21
+ ```bash
22
+ uv add nooa-bench
23
+ nemo-harbor --help
24
+ ```
25
+
26
+ See the [main repository](https://github.com/NVIDIA-NeMo/labs-OO-Agents) for
27
+ documentation.
28
+
29
+ Apache-2.0 licensed.
@@ -0,0 +1,9 @@
1
+ nooa_bench/__init__.py,sha256=PnppDSvQWWJK2au5iCLEXXuT3EW57U7mNuuR8uDa_LU,776
2
+ nooa_bench/bench_agent.py,sha256=XThiC8G-GSFEIu7b9iLP9WhhPxFpHoLJgK38rDxfOnA,10243
3
+ nooa_bench/protocol.py,sha256=h3xbUHd986MXPXF2guQPtFSb1OU6aPdDId8QPcYHUS8,6076
4
+ nooa_bench/runner.py,sha256=TkFUg6GdwmtL1N_eTPtrp0gcFgfXR9GQRT-K6wByKW0,8365
5
+ nooa_bench/trace_analyzer.py,sha256=Fg6WXj_vJDcHiq8-WxW6Et5AzHs9c5eRqmwuJ7oDneA,6663
6
+ nooa_bench-0.0.7.dist-info/METADATA,sha256=Dxy7TjFB0-regKpqn0rpKo8fb8wqgDLzGy-YT9_DGwQ,944
7
+ nooa_bench-0.0.7.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
8
+ nooa_bench-0.0.7.dist-info/entry_points.txt,sha256=3J9ysVhArjnuOz0ZjXMXXF6t-TSvqt5zJDOUEGmsFJ4,55
9
+ nooa_bench-0.0.7.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ nemo-harbor = nooa_bench.runner:main