Nous.CodeMode (nous v0.17.1)

Copy Markdown View Source

Mode resolution and the visible tool set for Code Mode.

Code Mode lets the model write a program that calls tools — looping, branching, fanning out — in one round trip, instead of emitting a chain of individual tool calls. The program runs on a Nous.CodeRuntime provider; the tools it may call are the same ones a direct call would reach, through the same permission policy.

Modes

  • :native — no Code Mode. The model sees the tools and calls them directly. This is what every existing agent does.
  • :code — the model sees only run_code. Tools are reachable from inside a program and nowhere else.
  • :both — the model sees the tools and run_code, and picks per turn. A single file_read is already ideal as a native call; a ten-step search is not.

Resolution order: the agent's :code_mode field, then config :nous, :code_mode, then :both. As with Nous.Sandbox.Policy.default_mode/0, a configured value may be a string (it often comes from an env var) and is matched against a literal whitelist — never String.to_atom/1.

Degrading without a runtime

:both needs a runtime to be worth advertising: with none configured it behaves as :native rather than showing the model a run_code that can only fail, since the native path is right there. :code is not degraded — an operator who asked for code mode gets run_code, and calling it without a provider returns an actionable error (see Nous.Tools.RunCode) instead of silently reverting to a mode they turned off.

Configure a provider with:

config :nous, :code_runtime, {Nous.CodeRuntime.JS, timeout_ms: 30_000}

Nous.CodeRuntime.JS ships here and needs the optional :tyrex dependency; any module implementing Nous.CodeRuntime works in its place.

run_code sits outside the restriction layers

visible_tools/4 injects run_code after Nous.Permissions.filter_tools/2 has run. A restriction that denies every tool still leaves Code Mode's entry point reachable — otherwise a deny-all policy would not restrict the agent, it would mute it. That is not a hole: run_code still dispatches through the whole pipeline, so a pre-tool hook or a permission plugin can inspect the program text before it executes, and every tool the program calls is filtered by the same policy through bindings/4.

One permission mechanism

bindings/4 derives the program's callable surface from Nous.Permissions.filter_tools/2. A granted tool becomes a real closure, already scoped to the caller; a denied one becomes a stub that returns an error naming the tool. Both appear, so a program that calls a denied tool reads a comprehensible refusal instead of crashing on an undefined function — and there is exactly one place that decides what may run.

Summary

Types

A tool dispatch: what actually runs a sub-call made from inside a program.

How the model reaches tools this run.

Functions

How long one run_code call may take, in milliseconds.

The bindings a program is given: one global, one function per tool.

What the model is told when a call collapses. Names the way back in, so the next turn is a run_code call rather than the same mistake.

Whether a model-direct call to tool_name collapses to "unknown tool".

The application-wide default mode.

Run one sub-call directly, with no scheduling in front of it.

The language the configured provider runs, as an SDK language.

The three modes.

Normalise a mode given as an atom or a string.

Mint a parent token for one run_code execution.

The mode for an agent: its own :code_mode, else default_mode/0.

The name of the transport tool.

Build the run_code tool for this request.

The configured runtime provider, or an error message explaining what to do.

Whether a usable runtime provider is configured.

Tag a synthesised tool call as transport, so collapsed?/3 lets it through.

Whether a tool call was synthesised by a running program rather than sent by the model.

The tool set the model sees, given mode.

Types

dispatch()

@type dispatch() :: (Nous.Tool.t(), map(), Nous.RunContext.t() ->
                 {:ok, term()} | {:error, term()})

A tool dispatch: what actually runs a sub-call made from inside a program.

Sub-call scheduling, ordering and session-event logging live behind this function, which is why bindings/4 takes it as an option instead of calling the executor directly.

mode()

@type mode() :: :native | :code | :both

How the model reaches tools this run.

Functions

await_timeout_ms()

@spec await_timeout_ms() :: pos_integer()

How long one run_code call may take, in milliseconds.

This is the caller's patience, not a budget the program can ask for: real budgets — wall clock, memory, instruction count — are provider configuration, validated when the provider is configured.

bindings(tools, policy, run_ctx, opts \\ [])

The bindings a program is given: one global, one function per tool.

Granted tools — those that survive Nous.Permissions.filter_tools/2 — get a closure that dispatches the real tool with the caller's context. Denied tools get a stub returning an error naming the tool. Both are present, so the program never hits an undefined function and never gets to distinguish "denied" from "failed" by the shape of what it caught.

Options

  • :dispatch — a dispatch/0 replacing the default, direct_dispatch/3. This is where a sub-call scheduler plugs in: serialisation, max_parallel, argument snapshots and session-event logging all live behind it.

direct_dispatch/3 is the default because it is the only thing a caller who owns no Nous.Agent.Context can honestly do — but it is not what the shipped Code Mode path uses. Nous.Tools.RunCode starts a Nous.CodeMode.Scheduler for each run and passes Nous.CodeMode.Scheduler.dispatch_fun/1 here, so a real program's sub-calls are ordered by one lane and audited into the session log. Calling bindings/4 with no :dispatch gets the unscheduled path: right for a test or a direct caller, wrong for a run.

collapse_message(tool_name)

@spec collapse_message(String.t()) :: String.t()

What the model is told when a call collapses. Names the way back in, so the next turn is a run_code call rather than the same mistake.

collapsed?(arg1, tool_name, call)

@spec collapsed?(mode(), String.t(), map()) :: boolean()

Whether a model-direct call to tool_name collapses to "unknown tool".

Under mode: :code the model was shown exactly one tool, so a call to any other one — hallucinated, or replayed from a transcript recorded in another mode — can only fail. It is refused here, before pre-tool hooks run: a guard must never be asked to approve a call that cannot execute, because an approval it grants is a decision about nothing.

A sub-dispatch made from inside a program carries a parent token (see tag_transport/2) marking it as transport rather than model, and is let through.

default_mode()

@spec default_mode() :: mode()

The application-wide default mode.

direct_dispatch(tool, args, run_ctx)

@spec direct_dispatch(Nous.Tool.t(), map(), Nous.RunContext.t()) ::
  {:ok, term()} | {:error, map()}

Run one sub-call directly, with no scheduling in front of it.

This is the default dispatch/0, and the one Nous.CodeMode.Scheduler sits on top of in the shipped run_code path rather than calling Nous.ToolExecutor.execute/3 itself: the executor has a three-shape return ({:ok, result}, {:ok, result, %ContextUpdate{}}, {:error, reason}) and a dispatch must hand back two. Folding that here, in one place, is what keeps a tool that returns a context update from looking like a contract breach to everything downstream.

The context update is dropped, loudly. There is no agent context at this depth to merge it into — a %Nous.RunContext{} is not one — so the honest options are "drop it and say so" or "lie". That is about a tool's deps update: applying those from concurrent sub-calls into a context the runner also owns is the two-writers hazard. The scheduler's own bookkeeping events are a different thing and do reach the session log, as :log_event operations on the update Nous.Tools.RunCode returns.

language()

@spec language() :: Nous.CodeMode.Sdk.language()

The language the configured provider runs, as an SDK language.

Falls back to :javascript when nothing is configured or the provider names a language this repo cannot generate an SDK for — a provider must not be able to break tool visibility by reporting an exotic language.

modes()

@spec modes() :: [mode()]

The three modes.

new(mode)

@spec new(term()) :: mode()

Normalise a mode given as an atom or a string.

Raises ArgumentError on anything else, at the point of configuration rather than mid-run.

Examples

iex> Nous.CodeMode.new(:code)
:code

iex> Nous.CodeMode.new("both")
:both

new_parent_token()

@spec new_parent_token() :: String.t()

Mint a parent token for one run_code execution.

resolve(arg1)

@spec resolve(map() | nil) :: mode()

The mode for an agent: its own :code_mode, else default_mode/0.

Takes anything with a :code_mode key, so it works on an agent, a plain map in a test, or nil.

Examples

iex> Nous.CodeMode.resolve(%{code_mode: :code})
:code

iex> Nous.CodeMode.resolve(nil)
:both

run_code_name()

@spec run_code_name() :: String.t()

The name of the transport tool.

run_code_tool(all_tools, granted, opts \\ [])

@spec run_code_tool([Nous.Tool.t()], [Nous.Tool.t()], keyword()) :: Nous.Tool.t()

Build the run_code tool for this request.

The generated SDK rides in the tool's description, which is where the model reads it: under mode: :code it is the only declaration of the tools that reaches the prompt at all. That also makes Nous.CodeMode.Sdk's byte stability load-bearing — this string is part of the prompt prefix of every request in the run.

retries: 0 is deliberate. A program has side effects by the time it fails; re-running it because the model's code threw would repeat them.

runtime()

@spec runtime() :: {:ok, {module(), term()}} | {:error, String.t()}

The configured runtime provider, or an error message explaining what to do.

Accepts {module, config} or a bare module (config defaults to []), and checks the module actually implements the behaviour, so a typo is reported here rather than as an UndefinedFunctionError inside a tool call.

runtime_configured?()

@spec runtime_configured?() :: boolean()

Whether a usable runtime provider is configured.

tag_transport(call, token)

@spec tag_transport(map(), String.t()) :: map()

Tag a synthesised tool call as transport, so collapsed?/3 lets it through.

transport_call?(call)

@spec transport_call?(term()) :: boolean()

Whether a tool call was synthesised by a running program rather than sent by the model.

visible_tools(mode, all_tools, granted, opts \\ [])

@spec visible_tools(mode(), [Nous.Tool.t()], [Nous.Tool.t()], keyword()) :: [
  Nous.Tool.t()
]

The tool set the model sees, given mode.

all_tools is the set before the permission policy ran and granted the set after; both are needed because the SDK declares what the program may call (granted) while its bindings also stub what it may not (all).

Injection happens here, after the filter, on purpose — see the moduledoc.

Options

  • :policy — the Nous.Permissions.Policy in force, carried into the bindings so the program is filtered by the same mechanism.