Skip to main content

nooa-codeact-advanced

Advanced tuning of NOOA strategies — CodeAct prefill, loop guards, text-only recovery callbacks, truncation, code restrictions, execution internals, and PredictStrategy tuning. Use when configuring CodeActConfig or PredictConfig beyond defaults, handling models that return prose instead of tool calls, writing a custom prefill, restricting generated code, or debugging truncation and eviction.

Source facts

Repository
NVIDIA-NeMo/labs-OO-Agents
Last source activity
October 21, 2026 at 12:42
Detected SKILL.md language
English
Stars
2,298
Forks
314

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
nooa-codeact-advanced
description
Advanced tuning of NOOA strategies — CodeAct prefill, loop guards, text-only recovery callbacks, truncation, code restrictions, execution internals, and PredictStrategy tuning. Use when configuring CodeActConfig or PredictConfig beyond defaults, handling models that return prose instead of tool calls, writing a custom prefill, restricting generated code, or debugging truncation and eviction.
compatibility
nooa package
# Advanced CodeAct (and Predict) Tuning The authoring basics are in `nooa-agent-authoring`. This skill covers the deep configuration surface, verified against `strategies/codeact.py`, `strategies/prefill.py`, `strategies/predict.py`, and `config/`. ## Config plumbing rules (read first) - Configuration fields go through **`config=`**: `CodeActStrategy(config=CodeActConfig(...))`, `PredictStrategy(PredictConfig(...))`. Flat config kwargs (`CodeActStrategy(max_iterations=10)`, `CodeActStrategy(prefill=...)`) do not exist. Strategy-level extension points such as `CodeActStrategy(on_text_only=...)` and `CodeActStrategy(error_formatter=...)` are separate keyword arguments. - All config objects are frozen Pydantic models with `merge_with(other)`: only fields explicitly set on `other` override. **Configs must be freshly constructed** — anything round-tripped through `model_dump()`/`model_validate()` has an empty `model_fields_set` and `merge_with` raises. - Truncation layers: framework default → `Agent` class kwarg `truncation=` → instance kwarg → `@strategy(..., truncation=...)` per method. `TruncationConfig.merge_with` deep-merges sub-configs field-by-field; the strategy configs merge flat. ## Prefill Before the first LLM turn, CodeAct runs up to two **synthetic `execute_python` cells** (tool-call id prefixed `prefill_`, `execution_count=0`). They cost no loop iterations, their failures are non-fatal (logged as an `Error` event), and any variables they define persist as live REPL locals for the model's first real turn. 1. **The prefill plugin** (default `InspectInputsPrefill()`): generates code that prints the call signature, `pprint()`s each parameter under `truncation.prefill_format` limits (defaults `max_string=2000, max_length=25, max_depth=4`), auto-`show()`s `Media` parameters for multimodal perception, and prints `doc()` of a complex return type. Per-parameter limits come from `Annotated[T, spec(max_string=..., max_length=...)]` on the signature. 2. **Pre-ellipsis code**: statements between the docstring and the `...` are extracted from the AST and run verbatim as the second prefill cell — with or without the plugin. ```python class Notifier(Agent, llm=llm): async def notify_stale(self) -> str: """Notify owners of stale tasks. Decide who needs pinging.""" overdue = [t for t in self.all_tasks() if t.overdue] # pre-ellipsis: runs first, print(f"{len(overdue)} overdue") # LLM sees output + variables ... ``` - **Disable** the plugin: `CodeActConfig(prefill=None)` (pre-ellipsis code still runs). The `CodeActStrategy.__init__` docstring claiming "prefill is always enabled" is outdated — the config is honored. - **Custom prefill**: any object implementing `get_code(self, call: CurrentCall, config=None) -> str | None` (return `None` to skip; `config` is the agent's `TruncationConfig`). Pass it via `CodeActConfig(prefill=MyPrefill())`. - CodeAct and PurePython use the active runtime truncation config, including method overrides, for prefill. `print_prompt()` previews the same settings. - Inside prefill cells only, the builtin `_call` (the `CurrentCall`) is available — that's how `InspectInputsPrefill` prints `doc(_call.return_type)`. - Pre-ellipsis code runs through the full validator/restriction pipeline; `await` is allowed (cells are wrapped in an async function), and async `self.method()` calls must be awaited or the whole cell is rejected. ## Loop guards and knobs (`CodeActConfig`) | Knob | Default | Verified semantics | |---|---|---| | `max_iterations` | `None` | **Unlimited.** The loop then stops only on completion, the error budget, or a hard abort. | | `max_retries` | `3` | **Cumulative session error budget, not consecutive** (the counter is never reset). LLM API errors, bad tool JSON, empty code, and `return_result` validation failures all count. | | `max_consecutive_text_only` | `3` | Consecutive no-tool-call text replies before hard abort; `0` disables. Any real tool call resets the counter. | | `cell_timeout` | `None` | Per-cell `asyncio.wait_for` limit in seconds; `None` = unlimited. Cannot interrupt a truly blocking sync syscall — that's what the blocking-call AST validation is for. | | `max_tokens` / `temperature` / `top_p` | `None` | Passed to every generation call when set (model defaults otherwise). On empty responses with `finish_reason="length"` CodeAct aborts and tells you to raise `max_tokens` (16384+ for reasoning models). | | `translate_tool_calls` | `False` | When a weak model calls an agent method directly as a tool (instead of via `execute_python`), rewrite it into equivalent code and run it — teaching the right pattern. Off = an error naming the valid tools and showing the rejected name as Python-cell code: the equivalent call for a public callable on `self`, `doc(self.name)` for an attribute object, or `doc(self)` when the name does not resolve. | | `restrictions` | `RestrictionsConfig()` | See Restrictions below. | | `prefill` | `InspectInputsPrefill()` | See Prefill above. | | `max_tool_calls` | `None` | **Dead — declared but never read.** Setting it does nothing. | | `loop_guard` | `None` | Opt-in `LoopGuardConfig()` (defaults `repeat_threshold=7`, `window=15`, `max_outcome_chars=500`). A call (tool name + normalized arguments; comments and formatting ignored) whose last `repeat_threshold - 1` runs in the window failed identically is not run again; one that produced identical output `repeat_threshold` times runs with a warning. Repeating either within the window after the warning raises `LoopDetectedError`. Messages are fixed templates; a `LoopGuardTriggered` event records each warning or stop. | `tool_choice` is hardcoded `"auto"`. ## Text-only recovery callback CodeAct expects each model turn to call `execute_python` or `return_result`. When a model emits only prose, NOOA preserves that exact assistant turn and then invokes the strategy's `on_text_only` callback. Do not pass `TextOnlyResponseAction` to `@strategy`. The objects have distinct roles: ```text @strategy attaches CodeActStrategy to a method -> CodeActStrategy(on_text_only=handler) registers the callback -> handler(TextOnlyResponseContext) returns TextOnlyResponseAction ``` The callback may be synchronous or asynchronous. Its context contains the untouched `LLMResponse`, normalized text, current method call, and declared return type. Its action chooses one of three append-only recovery paths: | Action constructor | Effect | |---|---| | `TextOnlyResponseAction.return_result(value)` | Validate `value` through CodeAct's normal result path. Invalid values produce model-visible correction feedback. | | `TextOnlyResponseAction.retry(*events)` | Append feedback events, then ask the model again. | | `TextOnlyResponseAction.tool_calls(*calls)` | Run synthetic calls while retaining the original assistant turn. Use `execute_python`, not TUI-only tools. | The safe default is `retry_text_only_response`: it appends an `Error` asking the model to call `return_result(value)` or `execute_python(code)`, then retries. Opt into accepting prose only when that is appropriate for the method contract: ```python from nooa import Agent, CodeActStrategy, return_text_as_result, strategy class Summarizer(Agent, llm=llm): @strategy(CodeActStrategy(on_text_only=return_text_as_result)) async def summarize(self, text: str) -> str: """Summarize the text.""" ... ``` For custom policy, return an action from the callback: ```python from nooa import TextOnlyResponseAction, TextOnlyResponseContext from nooa.events import Error def require_tool(context: TextOnlyResponseContext) -> TextOnlyResponseAction: return TextOnlyResponseAction.retry( Error(content=f"Plain text cannot finish {context.call.method_name}; call a tool.") ) @strategy(CodeActStrategy(on_text_only=require_tool)) async def investigate(self, question: str) -> str: ... ``` ## `return_result` mechanics The LLM finishes via the `return_result` tool, or `return_result(value)` inside a cell (raises a control-flow signal — not an error — stopping the cell immediately), or an explicit `return X` statement whose value validates against the return type. All three route through the same validation, and inline completions emit a synthetic `return_result` ToolCallEvent so trajectories always show the final answer. Coercion before validation, in order: bare values wrapped to the schema; string results resolved as REPL variable names; JSON / `ast.literal_eval` parsing; `ClassName(...)` constructor strings evaluated in an empty-builtins sandbox. Validation failures are fed back to the model (with a tip to call `return_result(variable)` from inside code) and count against `max_retries`. Non-JSON-schemable return types (DataFrames, arbitrary classes) degrade to an `Any` schema plus `doc(<type>)` in the tool description and an `isinstance` check at the end. ## Execution environment - **exec_globals** = the agent module's namespace across the full MRO (minus `hidden` names) + method parameters as variables + auto-injected symbols: `self`, `asyncio`, `typing` (+`Annotated/Any/Literal/Optional/Union`), `doc`, `pprint`, `show`, media classes, `strategy` + all strategy classes, `reasoning`, `return_result`. `help` is shadowed to `doc` so nothing blocks on stdin. - **REPL state persists** across cells (globals-declared wrapper); helper functions defined in cells persist as REPL locals — they are never attached to the agent, and attaching callables to `self` is validator-rejected. Return-type names are re-injected if the model clobbers them. `Out[n]` gives Jupyter-style access to prior cell values. - **Media**: `show(obj)` attaches images, audio, video, PDFs, PIL images, or matplotlib figures while a cell runs. The normal runtime keeps the first 5 attachments per cell; adjust `truncation.media_capture.max_attachments_per_execution`. Configure the model's `media_inputs` with accepted formats. See [nooa-media](../nooa-media/SKILL.md) for examples and size limits. - **Custom error formatting**: `CodeActStrategy(config=..., error_formatter=obj)` where `obj.format(error, code=None, *, line_offset=0) -> str`. Default is the IPython-style formatter (cell/line references, framework frames filtered, signature hint on call-shape TypeErrors). ## Truncation tuning (`TruncationConfig`) Two independent mechanisms — set them separately: 1. **Capture time** (`capture: CaptureConfig`) — per-cell stream caps with head/tail truncation: `max_stdout=50_000`, `max_stderr=2_000`, `max_error=10_000` chars; `tail=None` (half the budget); `file_backed=True` (default `False`) streams the *full* output to a temp file and puts its path in the truncation notice so the LLM can grep it. 2. **Render time** (`FormatConfig` triplet = `pformat` bounds `max_string/max_length/max_depth`): `event_format` (event fields, every turn; defaults 10_000/200/5), `prefill_format` (parameter inspection; 2_000/25/4), `context_block_format` (non-string context values; unlimited — overflow handled by block eviction). When input inspection actually omits values, NOOA emits a developer-facing `PrefillTruncationWarning` naming the method and the limits responsible. It is not injected into the model's stderr. Full arguments remain live REPL variables; the initial printed inspection can omit their middle or nested values. For a parameter the model must read completely, override only the relevant formatting limits; `None` disables that limit: ```python from typing import Annotated from nooa import spec async def analyze(self, document: Annotated[str, spec(max_string=None)]) -> str: """Analyze the complete document.""" ... ``` Precedence is defaults → agent class → instance → method `@strategy(..., truncation=...)` → per-parameter `spec()` metadata. For lists or nested data, adjust `max_length` or `max_depth` as needed. Use `TruncationConfig(prefill_format=FormatConfig(max_string=90_000))` to raise a method's default, or chunk inputs and let the agent inspect the relevant slices. Disabling formatting limits does **not** disable the separate `capture.max_stdout` cap (50,000 characters by default): raise it explicitly if the complete printed input can exceed that budget. A warning naming `capture.max_stdout` points to this second limit. `print_prompt()` displays generated prefill code; use an execution trace to verify captured output and the actual LLM request. Applications that deliberately accept truncated previews can filter `nooa.strategies.prefill.PrefillTruncationWarning` through Python's standard warnings filters. Context budget: `max_context_tokens` — or, when unset and the model exposes a context window, half the *usable* window (`(context_window - reserved_output) // 2`, where `reserved_output` is the call's `max_tokens`, else `TruncationConfig.response_reserve_tokens`, default 4096; setting `response_reserve_tokens=0` disables the auto-derived budget). Over-budget triggers **whole-block eviction** of SYSTEM context blocks — newest non-`static` user blocks first, marked `EVICTED: over context budget` in place. Static framework blocks (`system_prompt`, `self`) are never evicted. No token counter is required: eviction sizes blocks as `chars × ratio`, where the ratio is calibrated from each provider response's reported prompt tokens (cold-start ~4 chars/token before the first response). Inspect usage via `agent.context_stats` — token figures are provider-reported (`prompt_tokens`); the context-vs-events breakdown is attributed by character share and `ctx N%` is measured against the usable window. **Dead/stale knobs**: `max_event_tokens` and `min_preserved_events` are read by nothing — events are never evicted by the renderer. Older notes may mention removed flat fields such as `max_block_chars` or `max_stdout_chars`; trust `config/truncation_config.py`. ## Restricting generated code (`RestrictionsConfig`) ```python from nooa.runtime.restrictions import RestrictionsConfig CodeActConfig(restrictions=RestrictionsConfig( blocked_modules=frozenset({"subprocess", "socket", "requests"}), # hard: stripped + import-denied restricted_imports=frozenset({"os", "shutil"}), # soft: import statements denied blocked_calls={"time": frozenset({"sleep"})}, # per-module call denylist )) ``` - Defaults: 12 event-loop-hazard modules hard-blocked (subprocess, socket, smtplib, ...); blocking calls denied (`time.sleep`, `os.system`, `Thread.join`, `asyncio.run`, ...); `restricted_imports` **empty** — any other installed module is importable. - Always blocked regardless of config: `exec`/`eval`/`compile`/`__import__`/`input`/`breakpoint`/`globals`/`locals`/`vars`, `from x import *`, dangerous dunder access (`__class__`, `__globals__`, ...), process-exit calls, self-recursion into the currently-executing method, and infinite `while True` without break. - Process-global default: `set_restricted_imports(frozenset({...}))` / `get_restricted_imports()` (from `nooa.runtime.restrictions`) — applies to every subsequently-constructed `RestrictionsConfig` that doesn't set the field explicitly. Generated code cannot call these (they're in the forbidden-builtins list). - Violations are **not fatal**: the validator error comes back as the cell's error output with a fix hint, and the model retries (within the `max_retries` budget). - Agent-in-agent recursion depth is a separate class-level guard: `class MyAgent(Agent, execution=ExecutionConfig(max_nesting_depth=5))` (default 10; class kwarg only). ## Overriding the strategy prompt Not a config field — use the decorator's scoped-context: ```python from nooa.context_blocks import ScopedContext @strategy(CodeActStrategy(), ScopedContext(context={"strategy_prompt": "My leaner instructions..."})) async def solve(self, q: str) -> str: ... ```
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub