Skip to content

Commit b07e206

Browse files
committed
fix(errors): address diagnostic review feedback
Signed-off-by: Paul Furgale <pfurgale@nvidia.com>
1 parent 5d438b8 commit b07e206

23 files changed

Lines changed: 662 additions & 416 deletions

CHANGELOG.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ to follow semantic versioning.
77
## [Unreleased]
88

99
- Breaking: custom CodeAct error formatters must implement
10-
`format(error, code=None, *, line_offset=0, formatted_error="", max_error=None, tail_chars=None)`.
10+
`format(error, code=None, *, line_offset=0, max_error=None, tail_chars=None)`.
1111
Reduced legacy signatures are no longer supported.
12+
- Breaking: sandboxed user-code failures are exposed as `SandboxExecutionError`;
13+
inspect `original_type`, `original_error`, and `diagnostic` for worker-side details.
1214
- Initial public release of NVIDIA Object-Oriented Agents (NOOA).
1315
- Security: MCP server configurations no longer expand host environment variables
1416
from `${VAR}` placeholders. Trusted caller code must resolve secrets and pass

src/nooa/errors/formatting.py

Lines changed: 21 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -49,15 +49,7 @@
4949

5050
# Internal wrapper function names to replace with <module>
5151
_WRAPPER_NAMES = ("__repl_wrapper__", "__wrapper__")
52-
_TRUNCATED_DIAGNOSTIC_PATTERN = re.compile(
53-
r"\A<truncated-output>\n"
54-
r"Output too large \(([\d,]+) chars\)\. "
55-
r"Showing first ([\d,]+) and last ([\d,]+) chars\.\n"
56-
r"The ([\d,]+) chars in the middle are not recoverable\.\n\n"
57-
r"(.*?)\n\n\.\.\. ([\d,]+) chars not shown \.\.\.\n\n"
58-
r"(.*)\n</truncated-output>\Z",
59-
re.DOTALL,
60-
)
52+
6153

6254
# ---------------------------------------------------------------------------
6355
# Targeted model-recovery hints
@@ -78,19 +70,17 @@
7870
_HEREDOC_RE = re.compile(r"<<-?\s*['\"]?\w+['\"]?")
7971

8072
# Appended to SyntaxErrors that look like heredoc-in-quoted-string failures.
81-
_HEREDOC_HINT = """\
82-
Hint: this looks like a bash heredoc (`<<...`) embedded in a single- or
83-
double-quoted Python string. Python rejects multi-line single/double-quoted
84-
strings, which is why the parser errored before reaching the heredoc body.
73+
_HEREDOC_HINT = '''\
74+
Hint: this looks like a shell heredoc (`<<...`) embedded in a single- or
75+
multiple-line Python string. Put the complete command in a triple-quoted
76+
string, then pass that value using the callable's documented API:
8577
86-
Fix 1 (preferred) — use a triple-quoted Python string so newlines are legal:
87-
await shell.run(\"\"\"cat <<EOF
88-
content
89-
EOF\"\"\")
78+
command = """cat <<'EOF'
79+
content
80+
EOF"""
9081
91-
Fix 2 — write the script to a file first, then run it:
92-
await shell.write("/tmp/script.sh", script_text)
93-
await shell.run("bash /tmp/script.sh")"""
82+
Use `doc(...)` to inspect the available command runner and how it accepts
83+
commands or standard input.'''
9484

9585

9686
# Call-shape TypeError messages — a method/function called with the wrong
@@ -498,77 +488,11 @@ def _hard_bound_text(text: str, limit: int, *, closing: str = "") -> str:
498488
return marker[:limit]
499489

500490

501-
def _bound_preformatted_diagnostic(
502-
text: str,
503-
max_error: int | None,
504-
tail_chars: int | None,
505-
) -> str:
506-
"""Bound trusted backend text without nesting a valid truncation envelope."""
507-
text = text.rstrip()
508-
limit, tail = _diagnostic_budget(max_error, tail_chars)
509-
if len(text) <= limit:
510-
return text
511-
512-
match = _TRUNCATED_DIAGNOSTIC_PATTERN.fullmatch(text)
513-
if match is None:
514-
return _bound_diagnostic(text, max_error, tail_chars)
515-
516-
total_text, old_head_text, old_tail_text, dropped_text, head, repeated_text, tail_text = (
517-
match.groups()
518-
)
519-
try:
520-
total = int(total_text.replace(",", ""))
521-
old_head_chars = int(old_head_text.replace(",", ""))
522-
old_tail_chars = int(old_tail_text.replace(",", ""))
523-
dropped = int(dropped_text.replace(",", ""))
524-
repeated_dropped = int(repeated_text.replace(",", ""))
525-
except ValueError:
526-
return _bound_diagnostic(text, max_error, tail_chars)
527-
if (
528-
old_head_chars != len(head)
529-
or old_tail_chars != len(tail_text)
530-
or dropped != repeated_dropped
531-
or total != old_head_chars + old_tail_chars + dropped
532-
):
533-
return _bound_diagnostic(text, max_error, tail_chars)
534-
535-
desired_tail = limit // 2 if tail is None else tail
536-
desired_head = limit - desired_tail
537-
if old_head_chars <= desired_head and old_tail_chars <= desired_tail:
538-
return _hard_bound_text(
539-
text,
540-
limit + 1_024,
541-
closing="\n</truncated-output>",
542-
)
543-
544-
# The original middle is already gone, so retain as much of each requested
545-
# window as remains available and accurately describe the larger omission.
546-
bounded_head = head[:desired_head]
547-
bounded_tail = tail_text[-desired_tail:] if desired_tail else ""
548-
new_dropped = total - len(bounded_head) - len(bounded_tail)
549-
rebuilt = (
550-
"<truncated-output>\n"
551-
f"Output too large ({total:,} chars). "
552-
f"Showing first {len(bounded_head):,} and last {len(bounded_tail):,} chars.\n"
553-
f"The {new_dropped:,} chars in the middle are not recoverable.\n\n"
554-
f"{bounded_head}\n\n"
555-
f"... {new_dropped:,} chars not shown ...\n\n"
556-
f"{bounded_tail}\n"
557-
"</truncated-output>"
558-
)
559-
return _hard_bound_text(
560-
rebuilt,
561-
limit + 1_024,
562-
closing="\n</truncated-output>",
563-
)
564-
565-
566491
class ErrorFormatter(Protocol):
567492
"""Preferred strategy formatter contract.
568493
569-
Implementations receive trusted backend-rendered diagnostics and the resolved
570-
per-call error budget. Custom strategy formatters must implement this complete
571-
contract.
494+
Implementations receive the exception, source context, and resolved per-call
495+
error budget. Custom strategy formatters must implement this complete contract.
572496
"""
573497

574498
def format(
@@ -577,7 +501,6 @@ def format(
577501
code: str | None = None,
578502
*,
579503
line_offset: int = 0,
580-
formatted_error: str = "",
581504
max_error: int | None = None,
582505
tail_chars: int | None = None,
583506
) -> str: ...
@@ -601,7 +524,6 @@ def format(
601524
code: str | None = None,
602525
*,
603526
line_offset: int = 0,
604-
formatted_error: str = "",
605527
max_error: int | None = None,
606528
tail_chars: int | None = None,
607529
) -> str:
@@ -611,9 +533,6 @@ def format(
611533
error: The exception to format.
612534
code: Optional source code (used for syntax errors if text is missing).
613535
line_offset: Number of wrapper lines to subtract from line numbers.
614-
formatted_error: Trusted diagnostic already rendered by an execution
615-
backend. It bypasses local rendering so worker-local traceback
616-
and source context are preserved.
617536
max_error: Maximum retained diagnostic characters. ``None`` uses the
618537
framework default.
619538
tail_chars: Characters reserved for the retained tail. ``None`` uses
@@ -622,10 +541,15 @@ def format(
622541
Returns:
623542
Formatted error string with adjusted line numbers.
624543
"""
625-
if formatted_error:
626-
# Worker-generated truncation envelopes have already applied the
627-
# resolved policy; preserve one envelope while retaining a hard cap.
628-
return _bound_preformatted_diagnostic(formatted_error, max_error, tail_chars)
544+
from nooa.runtime.sandbox.errors import SandboxExecutionError
545+
546+
if isinstance(error, SandboxExecutionError):
547+
limit, _ = _diagnostic_budget(max_error, tail_chars)
548+
return _hard_bound_text(
549+
error.diagnostic.rstrip() or str(error),
550+
limit + 1_024,
551+
closing="\n</truncated-output>",
552+
)
629553

630554
if isinstance(error, SyntaxError):
631555
formatted = self._format_syntax_error(error, code, line_offset)
@@ -699,7 +623,6 @@ def format_error_for_llm(
699623
code: str | None = None,
700624
*,
701625
line_offset: int = 0,
702-
formatted_error: str = "",
703626
max_error: int | None = None,
704627
tail_chars: int | None = None,
705628
) -> str:
@@ -719,11 +642,6 @@ def format_error_for_llm(
719642
line_offset: Number of wrapper lines to subtract from line numbers.
720643
This compensates for lines added by the async wrapper (e.g.,
721644
"async def __repl_wrapper__():", "try:", etc.).
722-
formatted_error: Optional preformatted diagnostic produced by a trusted
723-
execution backend such as the sandbox worker. When non-empty, it
724-
bypasses local formatting (including ``code``, ``line_offset``, and
725-
bad-call hint handling). The producer is responsible for applying any
726-
required line adjustment.
727645
max_error: Maximum retained diagnostic characters. ``None`` uses the
728646
framework default.
729647
tail_chars: Characters reserved for the retained tail. ``None`` uses
@@ -737,7 +655,6 @@ def format_error_for_llm(
737655
error,
738656
code,
739657
line_offset=line_offset,
740-
formatted_error=formatted_error,
741658
max_error=max_error,
742659
tail_chars=tail_chars,
743660
)

src/nooa/events.py

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -663,11 +663,6 @@ class ExecutionResult(BaseModel):
663663
stdout: str = Field(default="", description="Captured stdout from execution")
664664
stderr: str = Field(default="", description="Captured stderr from execution")
665665
error: Exception | None = Field(default=None, description="Exception if execution failed")
666-
formatted_error: str = Field(
667-
default="",
668-
description="Parent-side formatted diagnostic reconstructed from a sandbox worker",
669-
exclude=True,
670-
)
671666
signal: ExecutionSignal | None = Field(
672667
default=None, description="Control flow signal (not an error), e.g. return_result()"
673668
)

src/nooa/runtime/event_backend.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -301,6 +301,15 @@ def remove_active_tag(self, tag: str) -> bool:
301301
def all_events(self) -> Iterator[EventBase]:
302302
return iter(self._events)
303303

304+
def iter_events(
305+
self,
306+
*,
307+
event_type: str | None = None,
308+
newest_first: bool = False,
309+
) -> Iterator[EventBase]:
310+
events = reversed(self._events) if newest_first else iter(self._events)
311+
return (event for event in events if event_type is None or event.event_type == event_type)
312+
304313
def find_tag(self, event: EventBase) -> str | None:
305314
for tag, e in self._tag_to_event.items():
306315
if e is event or e.id == event.id:

src/nooa/runtime/event_manager.py

Lines changed: 45 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@
1414
import logging
1515
import re
1616
from collections import defaultdict
17-
from collections.abc import Awaitable, Callable
18-
from typing import TYPE_CHECKING, Any
17+
from collections.abc import Awaitable, Callable, Iterable, Iterator
18+
from typing import TYPE_CHECKING, Any, cast
1919

2020
from nooa.context_blocks import EventStatus
2121
from nooa.context_blocks.models import Role
@@ -353,6 +353,7 @@ def filter(
353353
*,
354354
type: str | None = None,
355355
call_id: str | None = None,
356+
execution_status: str | None = None,
356357
query: str | None = None,
357358
regex: bool = False,
358359
limit: int | None = None,
@@ -362,37 +363,57 @@ def filter(
362363
Args:
363364
type: Event type filter (e.g., "Task", "PythonOutput")
364365
call_id: Call ID filter (matches metadata.call_id)
366+
execution_status: PythonOutput status filter (e.g. "error" or "complete")
365367
query: Text search (case-insensitive substring, or regex if regex=True)
366368
regex: If True, treat query as regex pattern
367369
limit: Maximum results (most recent first when limit < total)
368370
369371
Returns:
370372
List of matching events.
371373
"""
372-
events = list(self._backend.all_events())
373-
374-
# Apply type filter
375-
if type is not None:
376-
events = [e for e in events if e.event_type == type]
377-
378-
# Apply call_id filter
379-
if call_id is not None:
380-
events = [e for e in events if e.metadata.get("call_id") == call_id]
381-
382-
# Apply text/regex filter
383-
if query is not None:
384-
if regex:
385-
pattern = re.compile(query, re.IGNORECASE)
386-
events = [e for e in events if pattern.search(self._get_searchable_text(e))]
387-
else:
388-
query_lower = query.lower()
389-
events = [e for e in events if query_lower in self._get_searchable_text(e).lower()]
374+
if limit is not None and limit <= 0:
375+
return []
390376

391-
# Apply limit (from end = most recent)
392-
if limit is not None and len(events) > limit:
393-
events = events[-limit:]
377+
newest_first = limit is not None
378+
source: Iterable[EventBase]
379+
iter_events = getattr(self._backend, "iter_events", None)
380+
if newest_first and callable(iter_events):
381+
optimized_iterator = cast(Callable[..., Iterator[EventBase]], iter_events)
382+
source = optimized_iterator(event_type=type, newest_first=True)
383+
else:
384+
# ``iter_events`` is an optional optimization so existing custom
385+
# EventBackend implementations remain compatible. Unlimited queries
386+
# retain the backend's established snapshot behavior.
387+
events = self._backend.all_events()
388+
source = reversed(list(events)) if newest_first else events
389+
matches: list[EventBase] = []
390+
pattern = re.compile(query, re.IGNORECASE) if query is not None and regex else None
391+
query_lower = query.lower() if query is not None and not regex else None
392+
393+
for event in source:
394+
if type is not None and event.event_type != type:
395+
continue
396+
if call_id is not None and event.metadata.get("call_id") != call_id:
397+
continue
398+
if execution_status is not None:
399+
status = getattr(event, "execution_status", None)
400+
status_value = getattr(status, "value", status)
401+
if status_value != execution_status:
402+
continue
403+
if pattern is not None and not pattern.search(self._get_searchable_text(event)):
404+
continue
405+
if (
406+
query_lower is not None
407+
and query_lower not in self._get_searchable_text(event).lower()
408+
):
409+
continue
410+
matches.append(event)
411+
if limit is not None and len(matches) >= limit:
412+
break
394413

395-
return events
414+
if newest_first:
415+
matches.reverse()
416+
return matches
396417

397418
def _get_searchable_text(self, event: EventBase) -> str:
398419
"""Extract searchable text from an event's public fields."""

src/nooa/runtime/events.py

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ class EventsApi(Skill):
2929
events.query(limit=50) # recent 50
3030
events.query(type="Task") # all task events
3131
events.query(type="PythonOutput") # execution outputs
32+
events.query(type="PythonOutput", execution_status="error", limit=1)
3233
events.query(call_id="abc123") # events for one call
3334
events.query(query="error") # text search
3435
events.query(query="error.*db", regex=True) # regex search
@@ -45,15 +46,13 @@ class EventsApi(Skill):
4546
children = events[summary.children_tags]
4647
4748
Examples:
48-
# Find the last standalone Error event
49+
# Find the most recent strategy/retry Error event
4950
errors = events.query(type="Error", limit=1)
5051
51-
# Execution failures are PythonOutput events, not Error events
52-
failed_outputs = [
53-
output
54-
for output in events.query(type="PythonOutput")
55-
if output.execution_status.value == "error"
56-
]
52+
# Cell failures are PythonOutput events, not standalone Error events
53+
failed_outputs = events.query(
54+
type="PythonOutput", execution_status="error", limit=1
55+
)
5756
5857
# Get all outputs from current call
5958
outputs = events.query(type="PythonOutput", call_id=call_id)
@@ -83,6 +82,7 @@ def query(
8382
*,
8483
type: str | None = None,
8584
call_id: str | None = None,
85+
execution_status: str | None = None,
8686
query: str | None = None,
8787
regex: bool = False,
8888
limit: int | None = None,
@@ -95,6 +95,7 @@ def query(
9595
Args:
9696
type: Event type filter (e.g., "Task", "PythonOutput", "ToolCallEvent")
9797
call_id: Call ID filter (matches metadata.call_id)
98+
execution_status: PythonOutput status filter (e.g. "error" or "complete")
9899
query: Text search (case-insensitive substring, or regex if regex=True)
99100
regex: If True, treat query as regex pattern
100101
limit: Maximum results (most recent first when limit < total)
@@ -114,6 +115,7 @@ def query(
114115
return self._manager.filter(
115116
type=type,
116117
call_id=call_id,
118+
execution_status=execution_status,
117119
query=query,
118120
regex=regex,
119121
limit=limit,

0 commit comments

Comments
 (0)