Metadata-Version: 2.4
Name: nodus-workflow-ai
Version: 0.1.0
Summary: Run a generated plan as a Nodus graph: validation before execution, and a grant narrowed to what the plan declared
Author: Shawn Knight
License: MIT
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: nodus-lang>=5.8.0
Provides-Extra: agent
Requires-Dist: nodus-agent; extra == "agent"
Provides-Extra: dev
Requires-Dist: pytest; extra == "dev"

# nodus-workflow-ai

Run a **generated plan** as a Nodus graph: validated as a whole before any step
runs, under a capability grant narrowed to what the plan declared.

Design record: `docs/design/v5/07-generated-plans.md` in the
[nodus-lang](https://github.com/Masterplanner25/Nodus) repo. Issue:
[#93](https://github.com/Masterplanner25/Nodus/issues/93).

## What this is not

- **Not a planner.** `nodus-agent` has two (`LocalPlanner`, `LLMPlanner`).
- **Not an executor.** `task` / `graph` / `run_graph` already run a graph built
  from data at runtime.
- **Not a loop.** `goal … over … until` is the loop.

The execution substrate already existed. What was missing was the bridge from a
planner's flat list of steps to a DAG, and a contract for what is checked before
a generated plan is allowed to run.

## Usage

```python
from nodus import NodusRuntime
from nodus_agent import LocalPlanner
from nodus_workflow_ai import validate_plan, run_plan, PlanRejected

raw = LocalPlanner().plan("fetch then analyze", tools, context)

try:
    plan = validate_plan(raw, available_tools=[t["name"] for t in tools])
except PlanRejected as rejected:
    for finding in rejected.findings:
        print("rejected:", finding)
else:
    outcome = run_plan(plan, NodusRuntime(timeout_ms=None))
    print(outcome["result"]["steps"])      # {"fetch": ..., "analyze": ...}
    print(outcome["parallel_groups"])      # [["fetch"], ["analyze"]]
```

## The two things that carry the weight

### Validation happens before anything runs

A declared workflow is checked by the compiler. A generated one has no compiler,
so the checks have to be somewhere, and *"the model was careful"* is not
somewhere.

> A plan that will fail at step 7 because a tool does not exist is rejected at
> step 0. The run has not touched the world yet; after step 1 it has.

Checked: every named tool exists, every tool is permitted (when the host says
what it permits), the plan is under its step ceiling, names are unique, and every
`after` names a step that is actually in the plan.

Not checked, deliberately: **acyclicity and dependency resolution**, which come
free from construction — a dependency is a task *value*, so you cannot reference
one you do not have, and closing a cycle would need a forward reference. A second
implementation of a guarantee the substrate already gives is the one that drifts.

### The grant is narrowed to the plan

A generated plan runs under **the tools it declared**, not the host's ambient
policy. The plan names its tools before it runs; that list is exactly the grant
it needs. A plan attempting a tool it did not declare is refused — not because
the tool is forbidden in general, but because *this plan* did not ask for it.

That is a real strengthening over "run the plan with the host's policy", and it
costs nothing to compute: the tool list is already being collected for the
existence check.

## Dependency edges

A planner emits a flat list; a workflow is a DAG. Something has to decide the
edges.

| the planner said | this step depends on |
|---|---|
| `"after": ["fetch"]` | exactly that |
| `"after": []` | nothing — a declared root |
| nothing at all | the step before it |

An explicit empty list and an absent key are **different statements**. Collapsing
them would make a planner unable to declare parallelism without also annotating
every step it forgot.

The fallback is per step, not per plan: a plan may annotate some steps and not
others, and falling back wholesale because one step was silent would discard the
edges the planner *did* emit.

Edges are **not** inferred from data flow. That is where this wants to end up,
and it needs a plan format declaring each step's inputs and outputs, which
`PlannerBackend` does not have — a change to a published package's protocol, and
a second decision rather than a prerequisite.

## What this does not promise

**A generated plan's goal is not statically satisfiable.** For a declared goal,
`reached("label")` is checked against checkpoint literals in the source. A
generated plan has no literals — the predicate and the steps both come from the
model, so checking one against the other proves only that the model was
self-consistent. Synthesising labels to make the existing check pass would give a
check that cannot fail, which reads as a guarantee and is worse than an absent
one. **The budget bounds the loop; nothing else does.**

**A generated run is not resumable across processes.** A declared workflow
rebuilds from source on resume; a generated graph was built from a value that
existed in one process. This is the one place a generated workflow is genuinely
weaker than a declared one, and it should not be discovered by a user.

**A wrong edge is a real failure mode.** A model asserting `after: ["analyze"]`
on a step that does not need it costs parallelism; asserting the reverse costs
correctness. Validation bounds what a plan can *name*; it cannot judge whether an
edge is right.

## Requirements

`nodus-lang >= 5.8.0` — step names on `task()` landed in
[#679](https://github.com/Masterplanner25/Nodus/issues/679), and without them a
generated run reports an empty `steps` map.

`nodus-agent` is optional, and only for its planners.

## Tests

```
pytest tests/ -q
```
