Skip to main content

Nova-Harness

Let a model do the work. Keep your hands on the controls.

Automation for any LiteLLM model, with permissions and a human veto built in.

Python 3.10+ License: MIT Offline test suite Typed

from NovaHarness.harness import Agent

agent = Agent("gpt-4o-mini", root="./my-project")
print(agent.run("Find every TODO in this project and write them to TODO.md"))

The agent reads and searches freely. When it wants to write TODO.md, it stops and asks you:

Tool 'write_file' requires approval.
  reason: tool in ask list
  path: "TODO.md"
  content: "# TODOs\n\n- src/app.py:41: handle empty input\n..."
  3600s left to answer
Approve? [y/n]: _

That is the whole idea. The model proposes, the harness decides.


Why a harness?

A model alone only writes text. To automate real work it needs to read files, run commands and browse, and every one of those is a way for it to go wrong. The harness is the layer in between:

  • It runs the loop. Ask the model, run the tools it requests, feed results back, repeat until it answers.
  • It enforces the rules. Every tool call is checked against a policy before it runs.
  • It keeps a human in charge. Risky calls wait for a y. No answer means no.
  • It stays small. About 1,900 lines, so you can read all of it before you trust it.

Install

pip install nova-harness

Python 3.10 or newer. Give LiteLLM your provider key (export OPENAI_API_KEY=...) or point it at a local model. For DuckDuckGo web search: pip install "nova-harness[search]".

Install from source
git clone -b nova-harness https://github.com/HmedNejjar/Nova.git
cd Nova
pip install -e ".[dev]"
pytest

What a run looks like

sequenceDiagram
    autonumber
    actor You
    participant R as Runtime
    participant M as Model
    participant P as Policy
    participant H as Approver
    participant X as Executor

    You->>R: agent.run("write the summary to SUMMARY.md")
    R->>M: history + tool specs
    M-->>R: call write_file(path, content)
    R->>P: evaluate(call)
    P-->>R: ask
    R->>H: approve?
    H-->>R: yes
    R->>X: validate args, run with timeout, cap output
    X-->>R: ok
    R->>M: history + result
    M-->>R: "Done, SUMMARY.md written."
    R-->>You: "Done, SUMMARY.md written."

Every outcome comes back to the model as a result, even failures. It sees ok, error (with a message it can act on) or denied, and adapts.

The full path of one tool call
flowchart TD
    call["Tool call"] --> known{"Tool exists?"}
    known -->|no| e1["error: lists the tools that do"]
    known -->|yes| args{"Arguments parsed?"}
    args -->|no| e2["error: resend"]
    args -->|yes| pol{"Policy"}
    pol -->|allow| exec
    pol -->|ask| human{"Human approves?"}
    human -->|yes| exec
    human -->|"no / timeout / error"| den
    pol -->|deny| den["denied: 'Do not retry.'"]
    den --> cnt{"Same call denied<br/>max_denials times?"}
    cnt -->|yes| abort(["PolicyAbortError"])
    cnt -->|no| back["back to the model"]
    exec["Executor: check types, run in a thread with a timeout, cap output"] --> ok["ok"]
    exec -->|"bad args, crash, timeout"| e3["error"]

Safe by default

Reads run on their own. Anything that changes the world asks first.

Tools Default
Look read_file list_directory find_files search_text allow
Think calculator current_time allow
Change write_file run_command ask
Reach out fetch_url web_search ask
Remember save_to_memory delete_from_memory ask

File tools can only see the root folder you give the agent. web_search appears only when you plug in a search backend.

Make it yours

Add a tool

A typed function with a docstring. The docstring tells the model what it does, the type hints become its schema.

from NovaHarness.harness import Agent
from NovaHarness.tools import tool

@tool
def word_count(text: str) -> int:
    """Count the words in a piece of text."""
    return len(text.split())

agent = Agent("gpt-4o-mini", tools=[word_count], policy={"word_count": "allow"})

Your tool asks for approval until a policy says otherwise, so a new tool is never silently trusted.

Change the rules

from NovaHarness.harness import Agent, Policy

# Adjust the defaults...
Agent("gpt-4o-mini", policy={"run_command": "deny", "write_file": "allow"})

# ...or write the rulebook yourself. Anything unlisted is refused.
Agent("gpt-4o-mini", policy=Policy(
    allow=frozenset({"read_file", "list_directory", "calculator"}),
    default="deny",
))

If a tool appears in more than one list, the strictest wins: deny beats ask beats allow.

Change who answers

The default prompt is the terminal. For a web app, a bot, or a cron job, subclass ToolApprover:

from NovaHarness.harness import Agent, ToolApprover

class HeadlessApprover(ToolApprover):
    def approve(self, tool_call, reason):
        return tool_call.tool_name == "fetch_url"   # fetch pages, nothing else

Agent("gpt-4o-mini", approver=HeadlessApprover())

The base class denies everything, so a forgotten override fails closed.

Change the model

from NovaHarness.harness import Agent

Agent("gpt-4o-mini")
Agent("anthropic/claude-sonnet-5-5", temperature=0.2)
Agent("ollama_chat/llama3.1", api_base="http://localhost:11434")   # fully local

Or pass any object with a generate(messages, tools) method. That is how the tests run without a network.

It remembers

Conversations start empty, but the agent can keep what matters. Facts (name, preferences) and notes (recent context) live in a small SQLite file, ~/.nova_harness/memory.db, and are loaded into the system prompt at the start of each conversation. The model saves them itself, and each save asks for your approval.

from NovaHarness.harness import Agent, MemoryStore

Agent("gpt-4o-mini")                                    # default store
Agent("gpt-4o-mini", memory=MemoryStore("memory.db"))   # your own file
Agent("gpt-4o-mini", memory=False)                      # none

Facts are capped at 50 entries and refuse new ones when full. Notes are capped at 50, evict the oldest, and only the 5 most recent reach the prompt.

What it guarantees

Files Paths are resolved and must stay inside root. .., absolute paths and escaping symlinks are rejected.
Shell Always asks. Commands over 200 characters are refused, so the text you approve is the text that runs. 25 s limit, whole process tree killed on timeout. Variables named like secrets (KEY, TOKEN, SECRET, PASSWORD, CREDENTIAL) are stripped from its environment.
Network Always asks. http(s) only, public addresses only (no localhost, LAN or cloud metadata), redirects re-checked, 500 KB cap.
Approval Timeout, closed input or any error means no. A late answer to an old question can never approve a new one.
Runaway models max_steps bounds every run. A model that keeps retrying a denied call is stopped after max_denials.
Prompt injection Pages, files and memory are presented to the model as data, not instructions.

What it does not do. run_command is gated by your approval, not sandboxed: an approved command can reach anything your account can, so read it before you press y. Secret filtering is by name, a heuristic. fetch_url resolves a host and then connects, which leaves a DNS rebinding gap. A timed-out tool is abandoned, not killed, because Python cannot stop a thread.

The full security model is in the guide.

Reference

Agent options
Option Default
model required LiteLLM model string, or any object with generate()
tools () Extra @tool functions (same name as a built-in replaces it)
policy None dict of overrides, or a Policy that replaces the defaults
use_default_tools True False drops the built-ins
root "." Workspace folder for file and shell tools
search_fn None Search backend; enables web_search
memory True True, False, or a MemoryStore
approver CLIApprover() Who answers ask prompts
system_prompt None Replace the built-in prompt
instructions None Append to the built-in prompt
max_steps 20 Model turns per run
final_answer True At the step limit, ask once for a best-effort answer instead of raising
max_denials 3 Identical denied calls before the run is aborted
tool_timeout 30.0 Seconds a tool may run
**model_kwargs Passed to LiteLLM: temperature, max_tokens, api_base, api_key, ...

agent.run(prompt) continues the same conversation. agent.reset() starts a new one and re-reads memory.

Web search backends
from NovaHarness.harness import Agent
from NovaHarness.tools import brave_search, ddgs_search

Agent("gpt-4o-mini", search_fn=ddgs_search())    # DuckDuckGo, no key
Agent("gpt-4o-mini", search_fn=brave_search())   # reads BRAVE_API_KEY

Any function (query, max_results) -> [(title, url, snippet)] works.

Errors

Everything inherits from HarnessError.

What happens
Recoverable ToolNotFoundError InvalidArgumentsError ToolExecutionError Becomes an error result; the model retries
Fatal PolicyAbortError MaxStepsReachedError ModelError Raised out of agent.run()
Setup ToolDefinitionError Raised when a @tool is defined wrongly
from NovaHarness.harness import MaxStepsReachedError, ModelError, PolicyAbortError

try:
    agent.run("Refactor everything.")
except PolicyAbortError:
    ...   # the model kept retrying a denied call
except MaxStepsReachedError:
    ...   # out of steps and no answer (final_answer=False, or the last call returned only tool calls)
except ModelError:
    ...   # the provider call failed

If a run is aborted, every pending tool call still gets a result, so the conversation stays valid and the agent is reusable.

Project layout
src/NovaHarness/
├── harness/   agent, runtime (the loop), executor, policies, approval, memory, messages, state, errors
├── models/    the ModelClient protocol and LiteLLMClient
└── tools/     @tool, ToolRegistry, built-in tools and the default policy
tests/         offline tests with scripted fake models and approvers

Development

pip install -e ".[dev]"
pytest

The suite needs no API key. To test your own tools, script a FakeModelClient (see tests/fakes.py) with the calls you want the "model" to make and assert on what comes back.

For the full guide (tool rules, every option, the security model in depth), see GUIDE.md.

License

MIT. Built by Marhfoul Bakr.

Metadata

Release files for nova-harness 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for nova-harness 0.1.0
File Size Uploaded Provenance Actions
nova_harness-0.1.0.tar.gz 49.8 kB Without provenance Details for nova_harness-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for nova-harness 0.1.0
File Interpreter ABI Platform Actions
nova_harness-0.1.0-py3-none-any.whl Python 3 none any Details for nova_harness-0.1.0-py3-none-any.whl

Total release size: 87.3 kB

Release files / nova_harness-0.1.0.tar.gz

Download URL nova_harness-0.1.0.tar.gz
Size 49.8 kB
Tags Source
SHA-256 checksum
How to use checksums
72384946ccea00292a6050ebefbaa46e7eafb8b2dec7ecf2ef5784c5c34ad5ed
BLAKE2b-256 checksum
How to use checksums
b5f0248d46a234931b03f20b6aa395a835fb510108408e73987819e9cabd0c57
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.8

Release files / nova_harness-0.1.0-py3-none-any.whl

Download URL nova_harness-0.1.0-py3-none-any.whl
Size 37.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
b158691c53a02b4f227b7682a5746b0fce807499d9cd72adbeffe5cc1f1702c2
BLAKE2b-256 checksum
How to use checksums
7eb69ab8af4d5f7973e10bce212e84119621353aa38a9a7e87963b035a402bb2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.12.8

Security for nova-harness 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page