Skip to content

Commit 59b7057

Browse files
authored
Merge pull request #3 from skundu42/experiment/continuous-improvement
loop harness imporvements
2 parents 91c6b01 + 8a16d94 commit 59b7057

38 files changed

Lines changed: 3194 additions & 163 deletions

src/noah_code/agent.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,7 @@ class _PermissionSandboxedExecutor(SandboxedExecutor):
124124
("ws", "read_output"),
125125
("ws", "replace"),
126126
("ws", "run"),
127+
("ws", "run_trusted_readonly"),
127128
("ws", "search"),
128129
("ws", "write"),
129130
("ws", "write_file"),
@@ -469,6 +470,7 @@ def __init__(
469470
config.permission_rules,
470471
mode=config.mode,
471472
auto_approve=config.auto_approve,
473+
yolo=config.yolo,
472474
)
473475
self._engine.mode = config.mode
474476
self._approvals = approvals or ApprovalBroker(
@@ -859,14 +861,22 @@ async def handle(self, notification: dict[str, list]) -> RespondResult:
859861
in the notification. Understand the requested end state before acting.
860862
Conversational questions are first-class: answer them with
861863
``self.message(...)`` then return DONE. Do not emit bare assistant prose.
864+
IMPORTANT: ``self.message(...)`` is SYNCHRONOUS — call it WITHOUT
865+
``await`` (``self.message("...")``). Prefixing ``await`` raises
866+
``TypeError: object NoneType can't be used in 'await' expression``
867+
because it returns ``None``. All ``self.ws.*``, ``self.web.*``,
868+
``self.lsp.*``, ``self.processes.*`` calls ARE async and need ``await``.
862869
863870
Minimal tool cookbook:
864871
- ``await self.ws.list("**/*.py")`` lists files.
865872
- ``await self.ws.search("symbol")`` returns locations as text.
866873
- ``match = await self.ws.read("path.py", lines=(10, 30))`` returns an
867874
editable Match; ``await self.ws.replace(match, "replacement")`` edits it.
868875
- ``await self.ws.edit("path.py", "unique old text", "new text")`` is the
869-
simple string-edit form. ``await self.ws.write("new.py", content)`` creates files.
876+
simple string-edit form and takes exactly three arguments (path, old,
877+
new); two-argument calls are invalid. For a Match from read(), edit
878+
with ``await self.ws.replace(match, "replacement")`` instead.
879+
``await self.ws.write("new.py", content)`` creates files.
870880
- Prefer ``await self.ws.apply_patch(changes)`` for coherent edits. Each change is
871881
``{"path": ..., "old": exact_text_or_None, "new": replacement_or_None}``;
872882
one call validates and atomically commits the full batch.
@@ -878,6 +888,19 @@ async def handle(self, notification: dict[str, list]) -> RespondResult:
878888
long-running commands. Consume logs by cursor; do not poll without new work.
879889
- ``result = await self.ws.run("pytest -q")`` runs validation; inspect
880890
``result.returncode``, ``result.stdout``, and ``result.stderr``.
891+
``read_only=True`` skips approval ONLY for commands the engine
892+
recognizes as read-only (``git status/log/diff/show``, ``rg``,
893+
``grep``, ``ls``, ``find`` (no -delete/-exec), ``head``, ``tail``,
894+
``wc``, ``sed``, ``awk``, ``sort``, ``uniq``, ``cut``, ``tr``,
895+
``tac``, ``column``, ``pwd``, ``file``, ``stat``, ``test``). It is
896+
REJECTED for anything else — including ``pytest``, ``uv``, ``python``,
897+
and build commands — so do NOT pass ``read_only=True`` for those; run
898+
them with plain ``await self.ws.run(cmd)`` (YOLO auto-approves;
899+
``--auto`` prompts).
900+
- If the host was launched with ``--yolo``, every approval is granted
901+
automatically without prompting. That mode exists for throwaway or
902+
sandboxed environments only; do not assume it is active — write code
903+
that works under normal permission gating.
881904
- ``await self.web.fetch(url)`` reads a page; ``await self.web.search(query)``
882905
searches the public web. Both are read-only and allowed by default.
883906
- ``await self.github.list()`` / ``view(number)`` inspect pull requests.
@@ -919,10 +942,28 @@ async def handle(self, notification: dict[str, list]) -> RespondResult:
919942
or mutating ``gh pr`` through the shell.
920943
- Do not read secrets or expose sensitive environment values.
921944
945+
Forbidden inside sandboxed code cells (do NOT attempt - they always fail
946+
and burn turns): ``import os``, ``import sys``, ``import subprocess``,
947+
``import shutil``, ``import nooa``, ``import noah_code``, and any of
948+
``eval()``, ``exec()``, ``compile()``, ``__import__()``. If you need to
949+
run shell logic or a host feature, do it with ``self.ws.run(...)``
950+
(read-only shell is auto-approved), ``self.web``, ``self.lsp``, or the
951+
dedicated tools - never by importing a blocked module. Use
952+
``self.ws.inspect(...)`` on Python data; do not try to reach host
953+
objects from a cell.
954+
922955
Return exactly one valid RespondResult:
923956
- DONE - request complete
924957
- NEED_INPUT - user input genuinely required
925958
- WAIT - a registered background job is still running
959+
960+
To end the turn, call ``return_result(RespondReason.DONE,
961+
explanation="...")`` — ``return_result`` takes a ``RespondReason``
962+
and an ``explanation`` string, NEVER a bare string as ``result=``
963+
(that raises ``return_result validation error: 'result' has wrong
964+
type``). To show the user text, call the synchronous
965+
``self.message("...")`` first (no ``await``), then
966+
``return_result(RespondReason.DONE, explanation="summary")``.
926967
"""
927968
...
928969

src/noah_code/approvals.py

Lines changed: 22 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -85,15 +85,6 @@ async def require(self, decision: PermissionDecision) -> None:
8585
raise PermissionError(
8686
f"rejected [{decision.category}] {decision.target}: {decision.reason}"
8787
)
88-
if choice == ApprovalChoice.SESSION:
89-
self._engine.add_session_rule(
90-
PermissionRule(
91-
category=decision.category,
92-
pattern=decision.remember_pattern,
93-
action="allow",
94-
reason="remembered for session",
95-
)
96-
)
9788

9889
async def _ask(self, decision: PermissionDecision) -> ApprovalChoice:
9990
req_id = str(uuid.uuid4())
@@ -128,7 +119,28 @@ async def _ask(self, decision: PermissionDecision) -> ApprovalChoice:
128119
async def _resolve() -> None:
129120
try:
130121
async with self._ui_lock:
131-
choice = await handler(request)
122+
# A concurrent twin of this request may have just been
123+
# granted a session-wide allow. Re-check before putting
124+
# a second identical prompt in front of the user, and
125+
# register new session rules inside the same critical
126+
# section so queued twins observe them immediately.
127+
fresh = self._engine.decide(decision.category, decision.target)
128+
if fresh.allowed:
129+
choice = ApprovalChoice.ONCE
130+
else:
131+
choice = await handler(request)
132+
if (
133+
choice == ApprovalChoice.SESSION
134+
and not fut.done()
135+
):
136+
self._engine.add_session_rule(
137+
PermissionRule(
138+
category=decision.category,
139+
pattern=decision.remember_pattern,
140+
action="allow",
141+
reason="remembered for session",
142+
)
143+
)
132144
except Exception as exc:
133145
if not fut.done():
134146
fut.set_exception(exc)

src/noah_code/budget.py

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from __future__ import annotations
1515

1616
import asyncio
17+
import math
1718
import threading
1819
import time
1920
from typing import Any
@@ -25,6 +26,30 @@ class BudgetExceeded(RuntimeError):
2526
"""Raised when a configured session cap would be exceeded."""
2627

2728

29+
def _sanitize_tokens(value: Any) -> int:
30+
"""Garbage-proof a token count; providers occasionally report NaN/inf."""
31+
32+
try:
33+
number = float(value)
34+
except (TypeError, ValueError):
35+
return 0
36+
if not math.isfinite(number):
37+
return 0
38+
return max(int(number), 0)
39+
40+
41+
def _sanitize_cost(value: Any) -> float:
42+
"""Drop NaN/garbage cost, keep +inf so a broken pricing feed fails closed."""
43+
44+
try:
45+
number = float(value)
46+
except (TypeError, ValueError):
47+
return 0.0
48+
if math.isnan(number):
49+
return 0.0
50+
return max(number, 0.0)
51+
52+
2853
class BudgetGuard:
2954
"""Thread-safe accumulator against optional token/cost/wall-clock caps."""
3055

@@ -66,9 +91,9 @@ def add_usage(
6691
cost_usd: float = 0.0,
6792
) -> None:
6893
with self._lock:
69-
self._prompt_tokens += max(int(prompt_tokens), 0)
70-
self._completion_tokens += max(int(completion_tokens), 0)
71-
self._cost_usd += max(float(cost_usd), 0.0)
94+
self._prompt_tokens += _sanitize_tokens(prompt_tokens)
95+
self._completion_tokens += _sanitize_tokens(completion_tokens)
96+
self._cost_usd += _sanitize_cost(cost_usd)
7297

7398
def enforce(self) -> None:
7499
"""Raise BudgetExceeded when any configured cap is breached."""
@@ -92,7 +117,7 @@ def sync_cost_usd(self, total_cost_usd: float) -> None:
92117
"""
93118

94119
with self._lock:
95-
self._cost_usd = max(self._cost_usd, max(float(total_cost_usd), 0.0))
120+
self._cost_usd = max(self._cost_usd, _sanitize_cost(total_cost_usd))
96121
self.enforce()
97122

98123
def observe_cost_usd(self, total_cost_usd: float) -> None:
@@ -104,7 +129,7 @@ def observe_cost_usd(self, total_cost_usd: float) -> None:
104129
"""
105130

106131
with self._lock:
107-
self._cost_usd = max(self._cost_usd, max(float(total_cost_usd), 0.0))
132+
self._cost_usd = max(self._cost_usd, _sanitize_cost(total_cost_usd))
108133
if self.exceeded is None:
109134
self.exceeded = self._breach()
110135

@@ -143,9 +168,9 @@ def load_state(self, data: dict[str, Any] | None) -> None:
143168
if not data:
144169
return
145170
with self._lock:
146-
self._prompt_tokens = max(int(data.get("prompt_tokens", 0)), 0)
147-
self._completion_tokens = max(int(data.get("completion_tokens", 0)), 0)
148-
self._cost_usd = max(float(data.get("cost_usd", 0.0)), 0.0)
171+
self._prompt_tokens = _sanitize_tokens(data.get("prompt_tokens", 0))
172+
self._completion_tokens = _sanitize_tokens(data.get("completion_tokens", 0))
173+
self._cost_usd = _sanitize_cost(data.get("cost_usd", 0.0))
149174
started_at = float(data.get("started_at", time.time()))
150175
self._started_wall = min(started_at, time.time())
151176
elapsed = max(time.time() - self._started_wall, 0.0)
@@ -176,7 +201,7 @@ def _cost_from_response(response: Any, usage: dict[str, Any]) -> float:
176201
import litellm
177202

178203
cost = litellm.completion_cost(completion_response=raw)
179-
return max(float(cost or 0.0), 0.0)
204+
return _sanitize_cost(cost)
180205
except Exception: # noqa: BLE001 - pricing must never break a turn
181206
return 0.0
182207

@@ -192,12 +217,19 @@ def _usage_from_response(response: Any) -> tuple[int, int, float]:
192217

193218
usage = getattr(response, "usage", None)
194219
usage_dict = usage if isinstance(usage, dict) else {}
195-
prompt = int(usage_dict.get("prompt_tokens") or usage_dict.get("input_tokens") or 0)
196-
completion = int(usage_dict.get("completion_tokens") or usage_dict.get("output_tokens") or 0)
220+
prompt = _sanitize_tokens(usage_dict.get("prompt_tokens") or usage_dict.get("input_tokens"))
221+
completion = _sanitize_tokens(
222+
usage_dict.get("completion_tokens") or usage_dict.get("output_tokens")
223+
)
197224
cost = _cost_from_response(response, usage_dict)
198225
try:
199226
if isinstance(usage, dict):
200-
usage.setdefault("cost_usd", cost)
227+
existing = usage.get("cost_usd")
228+
if existing is None or not math.isfinite(float(existing)):
229+
# Never leave provider-reported NaN/inf on the telemetry seam.
230+
usage["cost_usd"] = cost
231+
else:
232+
usage.setdefault("cost_usd", cost)
201233
elif cost > 0.0:
202234
response.usage = {"cost_usd": cost}
203235
except Exception: # noqa: BLE001 - telemetry must never break a turn

0 commit comments

Comments
 (0)