Metadata-Version: 2.4
Name: smartmdao-agents
Version: 0.1.0
Summary: Lets AI agents author and run SmartMDAO MDA/MDAO pipelines, with validation and traceability.
Author: WGhami
Author-email: WGhami <prideful-dill-cape@duck.com>
License-Expression: MIT
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: License :: OSI Approved :: MIT License
Requires-Dist: cloudpickle>=3.1.2
Requires-Dist: smartmdao>=1.5.0
Requires-Python: >=3.11
Description-Content-Type: text/markdown

# smartmdao-agents

[![CI](https://github.com/wghami/SmartMDAO-agents/actions/workflows/ci.yml/badge.svg)](https://github.com/wghami/SmartMDAO-agents/actions/workflows/ci.yml)

Turns a plain-English engineering task into a validated, sandboxed, auditable run of
[SmartMDAO](https://github.com/wghami/SmartMDAO) code. Hand it a task description and a way to
call your LLM; it hands back a resolved result, or a structured reason why not.

**Status: Phase 1 (MDA) and Phase 2 (MDAO) complete.** A separate project from `smartmdao` on
purpose - `smartmdao` stays a general-purpose, agent-agnostic runtime; this repo is the fast-
moving orchestration layer on top (prompts, validation rules, sandboxing).

## How it works

This repo never calls an LLM itself - no SDK dependency, no provider lock-in. You supply
`call_model`: a plain function taking a prompt string and returning the model's raw text
response. Everything else - building the prompt from the curated reference doc, extracting code
from the response, validating it, running it in a sandboxed subprocess, and retrying with the
error fed back on failure - is handled for you.

```mermaid
flowchart TD
    subgraph caller["You supply"]
        callmodel["call_model(prompt) -> str\na thin wrapper around your LLM SDK"]
    end

    subgraph pkg["run_agent_task(task, inputs, call_model=...)"]
        extract["build prompt → call_model → extract code"]
        runpipeline["validate (AST allowlist)\n→ sandboxed subprocess\n→ JSON audit record"]
        retry["on failure: retry with\nthe error fed back"]

        extract --> runpipeline
        runpipeline -->|"status != success,\nattempts remaining"| retry
        retry --> extract
    end

    callmodel <-.-> extract
    runpipeline --> result["RunRecord: status, outputs, error"]
```

```python
from smartmdao_agents import run_agent_task

def call_model(prompt: str) -> str:
    return my_llm_client.complete(prompt)  # wrap whatever you're already using

record = run_agent_task(
    "compute shipping cost from weight and distance",
    {"weight_kg": 4.0, "distance_km": 320, "express": True},
    call_model=call_model,
    max_attempts=3,
)
record.status   # "success" | "validation_failed" | "execution_failed" | "timeout"
record.outputs  # every resolved variable, or None on failure
```

Already have code (not fresh off an LLM call) and just want it validated/sandboxed/recorded?
`run_agent_pipeline(code, inputs)` skips the prompt/extract/retry machinery - `run_agent_task` is
built on top of it, not a replacement. It's also what the CLI uses:

```
smartmdao-agents run --file pipeline.py --inputs '{"width": 2.0}'
```

## Examples

Both of these are real: the exact task description that was sent, the exact code an isolated
model produced from it - nothing hand-written, nothing edited - and the verified result of
actually running that code. See [docs/dogfooding.md](docs/dogfooding.md) for the full methodology
and all four blind-test results (these two, plus a linear-MDA and an unconstrained-MDAO task).

### Non-numeric MDA — the capability that differentiates this from OpenMDAO/GEMSEO

> Write a smartmdao pipeline that resolves which spacecraft sensors can be enabled within a power
> budget. There is a fixed candidate list of sensor names and a dict mapping each sensor name to
> its power draw (define these as module-level constants). One step should propose enabling
> sensors one at a time, in a fixed deterministic order, as long as the cumulative power draw of
> already-enabled sensors stays within a `power_budget` input (float), stopping once the next
> sensor would exceed the budget. A second step should review/pass through the enabled set
> unchanged. Converge these two steps to a fixed point using smartmdao's non-numeric convergence
> support. Accept `power_budget` as the only external input.

```python
@pipeline.step(outputs=["selection"])
def a_propose(
    power_budget: float,
    reviewed_selection: SensorSelection = SensorSelection(enabled=frozenset()),
) -> SensorSelection:
    enabled = set(reviewed_selection.enabled)
    total = sum(SENSOR_POWER_DRAW[name] for name in enabled)
    for name in CANDIDATE_SENSORS:
        if name in enabled:
            continue
        draw = SENSOR_POWER_DRAW[name]
        if total + draw <= power_budget:
            enabled.add(name)
            total += draw
        else:
            break
    return SensorSelection(enabled=frozenset(enabled))

@pipeline.step(outputs=["reviewed_selection"])
def b_review(selection: SensorSelection) -> SensorSelection:
    return selection
```

Full fixture: [`tests/fixtures/safe/sensor_power_budget.py`](tests/fixtures/safe/sensor_power_budget.py).
With `power_budget=50.0`, converges in 2 iterations to all 5 sensors enabled - `HybridSolver`
detected the `a_propose ↔ b_review` cycle automatically, on a coupling variable that's never a
number anywhere.

### Constrained MDAO

> Write a smartmdao MDAO problem for designing an open-top rectangular box (no lid) with a fixed
> height of 10. Design variables are `width` and `depth`. Minimize the total material used
> (surface area: the bottom plus the four sides), subject to the box holding at least a volume of
> 200 (width * depth * height >= 200).

```python
@pipeline.step(outputs=["surface_area", "volume_slack"])
def evaluate_box(width, depth, height):
    bottom = width * depth
    sides = 2 * (width * height) + 2 * (depth * height)
    surface_area = bottom + sides
    volume = width * depth * height
    return surface_area, volume - MIN_VOLUME  # a slack, not the raw volume

evaluator = PipelineEvaluator(pipeline, design_vars=["width", "depth"], constants={"height": HEIGHT})
problem = OptimizationProblem(
    evaluator=evaluator,
    initial_guess=[5.0, 5.0],
    bounds=[(0.1, 100.0), (0.1, 100.0)],
    objective="surface_area",
    constraints=[ConstraintSpec(name="volume_slack", kind="ineq", multiplier=1.0)],
)
```

Full fixture: [`tests/fixtures/safe/mdao_constrained_box.py`](tests/fixtures/safe/mdao_constrained_box.py).
Converges to `width = depth ≈ 4.472`, matching the textbook-optimal square-base ratio
(`w = d = √(V/h)`) - an independent check that the answer is actually right, not just that
nothing crashed.

## Known gotchas

Found by the dogfooding in [docs/dogfooding.md](docs/dogfooding.md), fixed in `reference.md`, and
pinned down with regression tests so they can't silently regress:

- **Cycle execution order.** Cyclic steps run in alphabetical order by function name, every
  iteration. A step seeding a cycle via a Python default has to sort *before* the step consuming
  its output, or the first iteration fails.
- **`OptimizationProblem.objective` defaults silently** to the literal string `"objective"` -
  fails with an opaque `KeyError` if you forget to set it to a real output name.
- **A constraint must encode a slack (`value - threshold`), never the raw value.** Constraining
  the raw value only enforces `value >= 0` - the optimizer will silently ignore any real
  threshold and still report success. This was the most severe finding: a wrong answer with no
  error anywhere.

## Roadmap

- **Phase 1 (done)** — MDA, protocol-agnostic: curated reference doc, AST validator, subprocess
  sandbox with rlimits/timeout, JSON audit trail, one entry point + CLI.
- **Phase 2 (done)** — MDAO as an additive toggle: `problem: OptimizationProblem` auto-detected
  alongside `pipeline: Pipeline`, backend selectable per call (`scipy`/`openturns`).
- **Caller-side glue (done)** — `run_agent_task` + the retry-with-error-feedback loop, behind a
  single caller-supplied `call_model` function.
- **Phase 3 (not started)** — protocol adapters (MCP, LangChain, etc.) over the same contract.

## Development

```
uv sync
uv run pytest        # 100% coverage enforced via pytest.ini
```

Depends on [`smartmdao`](https://pypi.org/project/smartmdao/) from PyPI (`>=1.5.0`, the first
release with non-numeric convergence support). License: [MIT](LICENSE).
