Metadata-Version: 2.4
Name: meander-agent
Version: 0.6.1
Summary: Typed Python client for integrating agents with meander.
Author-email: Raphael Feikert <r.feikert@symbolic-intelligence.de>
Requires-Python: >=3.11
Requires-Dist: opentelemetry-exporter-otlp-proto-http<2,>=1.30
Requires-Dist: opentelemetry-sdk<2,>=1.30
Provides-Extra: claude
Requires-Dist: claude-agent-sdk<0.3,>=0.2; extra == 'claude'
Provides-Extra: openai
Requires-Dist: openai-agents<0.19,>=0.18.1; extra == 'openai'
Provides-Extra: test
Requires-Dist: pytest-randomly>=3; extra == 'test'
Requires-Dist: pytest>=8; extra == 'test'
Description-Content-Type: text/markdown

# meander-agent

Typed Python client for the **plan-sending side** of meander.

Your agent records what it intends to do and the claims behind it with
provenance. It also names the policy conclusion that would authorize the
action.
This package turns that into a `meander.plan`, checks its shape,
and attaches it to a real OpenTelemetry span.
Your OTLP export carries the span to meander.
The client never imports Meander or evaluates an ontology. It may transport a
customer-declared world/action contract, but never policy rules; those are
authored and assigned by an operator in Meander. Meander remains the
authoritative parser, checker, and evaluator.

## The model in one paragraph

A `meander.plan` is an **intended action plus evidence plus one policy goal**,
not a decision:

- **action**: the one thing the agent wants to do, declared up front
  (`kind`, `name`, `description`, `target: {entity, identity}`). This is
  the agent's statement of intent — meander shows it as "what the agent wants".
  It is **required** and is never written as a world fact. Its prose description
  has no policy meaning; on the server, `name`, `kind`, and target must match an
  ontology action contract that fixes the conclusion to evaluate.
- **facts**: what the agent observed or judged. Each fact carries its own
  provenance (`origin.kind`: `tool` | `api` | `human` | `agent`). A tool result
  is `tool`; the agent's own judgement is `agent`.
- **relations**: claimed relationships between entities (optional).
- **policy_goal**: the exact conclusion a policy must derive to authorize the
  action, expressed as a declared relation and result object. It names no rule.
  The agent repeats it for an explicit wire contract but cannot freely choose it:
  Meander rejects any difference from `ontology.actions`. Meander evaluates only
  policies assigned to the agent. If none proves the goal, an operator decides.

The client checks only the **shape**: required fields, types, one required
`action`, exactly one policy goal, the `origin` vocabulary, and
`plan_version == 2`. Whether the entities, properties, relations, and
derivations really exist is known only to
the server, because only the server has your ontology.
That is why this package does not import meander and loads no ontology.

**Guarantees (never a silent no-op):**
a failed run never attaches an attribute;
a span that can no longer be written refuses the attribute out loud
(`AttachResult(ok=False, span_not_recording)`);
a plan with a bad shape is reported with a field path instead of being written
half-way.

## Installation

```
pip install meander-agent            # transport + emitter (slim, no LLM SDK)
pip install "meander-agent[claude]"  # + Claude Agent SDK binding
pip install "meander-agent[openai]"  # + OpenAI Agents SDK binding
```

## 1. Connect, then send one real plan

You do not need to define an ontology or vocabulary before the first run. Call
`client.connect()` once to verify transport without executing the agent. Every
`client.run()` is then one real business run. When it contains unknown schema,
Meander pauses that exact plan, asks a human to confirm the schema, and resumes
the same plan after approval. The customer never repeats the business action.

If you already have a stable contract, you can pass an explicit vocabulary:

```python
vocabulary = {
    "entities": {
        "Order":   {"identity": ["order_id"], "properties": ["amount", "risk"]},
        "Case":    {"identity": ["case_id"],  "properties": []},
        "Outcome": {"identity": ["outcome_id"], "properties": []},
    },
    "relations": {
        "concerns": {"from": "Case", "to": "Order"},
        "may_release": {"from": "Order", "to": "Outcome", "derived": True},
    },
    "actions": {
        "release_order": {
            "kind": "tool_call",
            "target": "Order",
            "policy_goal": {
                "relation": "may_release",
                "object": {
                    "entity": "Outcome",
                    "identity": {"outcome_id": "allow"},
                },
            },
        },
    },
    "derivation_ids": ["requires_review.high_value"],
}
```

With an explicit vocabulary, the prompt and JSON schema allow only those names;
action names and kinds are bound to the declared action contracts. The server
still performs the authoritative, action-specific semantic validation.
Without one, the SDK asks for stable names grounded in the real run and emits a
marker that allows schema discovery. The marker does not turn the plan into a
disposable setup run. In both modes the ontology in Meander remains the source
of truth.

## 2. Set up transport

If you have no OTel of your own, one call is enough.
`endpoint` is Meander's shared full OTLP traces URL. `source_key` is the
source's bearer token and resolves its durable internal identity:

```python
from meander_agent import init_meander

client = init_meander(
    endpoint="https://<host>/v1/traces",
    source_key="<bearer-token>",
)
connected = client.connect()  # transport probe only, no agent run and no plan
# client.tracer  -> the wired OTel tracer
# client.shutdown() / client.force_flush()  -> finish the export
```

`init_meander` sets no global provider; the client keeps its own.
If you already have an OTel setup, skip `init_meander` and pass your tracer
directly (see section 5).

## 3. Run an agent (Claude)

The repository contains credential-gated live provider tests. Run the relevant
provider smoke before publishing a release that changes a binding.

```python
from meander_agent.claude import run_with_plan

result = run_with_plan(
    "Handle order ORD-42. Call the usual tools, claim the facts you gathered "
    "with their provenance, and propose the intended action with its policy "
    "goal. Do not make the decision yourself.",
    tracer=client.tracer,
)
print("attached" if result.attached else f"no plan set: {result.error_state}")
client.shutdown()   # export the span
```

The binding opens the root span, runs the model with structured output,
locks on error or abort, and on success attaches the checked plan.
It returns a `RunResult` (see section 4).
Needs the `[claude]` extra and an `ANTHROPIC_API_KEY`.

## 3b. The same run over OpenAI (official SDK pattern)

The `[openai]` extra binds the [OpenAI Agents SDK](https://openai.github.io/openai-agents-python/).
You write ordinary agent code (`Agent`, `Runner.run`, `final_output_as`); meander only
supplies the prompt fragment and the structured `output_type`, and finalizes the run:

```python
import os
from agents import Agent, Runner
from meander_agent import init_meander, meander_run
from meander_agent.openai import PlanModel, plan_output_type

client = init_meander(
    endpoint=os.environ["MEANDER_OTLP_ENDPOINT"],
    source_key=os.environ["MEANDER_SOURCE_KEY"],
)

with meander_run(tracer=client.tracer) as run:
    agent = Agent(
        name="History Tutor",
        instructions="You answer history questions clearly and concisely.\n\n" + run.prompt_fragment,
        output_type=plan_output_type(PlanModel),
    )
    result = await Runner.run(agent, "When did the Roman Empire fall?")
    plan = result.final_output_as(PlanModel, raise_if_incorrect_type=True)
    meander_result = run.finalize(plan.model_dump(by_alias=True), None)

client.shutdown()
print(meander_result.plan)
```

If you would rather not write that glue, the binding ships the one-liner
`run_with_plan(task, *, tracer, vocabulary=None)`, which wraps the same
`Runner.run` -> `final_output_as` -> `run.finalize` chain for you (async
variant: `run_with_plan_async`):

```python
from meander_agent.openai import run_with_plan

result = run_with_plan(task, tracer=client.tracer)
```

In a Jupyter notebook, use `await Runner.run(...)` directly; do not wrap it in
`asyncio.run(...)`, because the notebook already runs an event loop.

## 4. What you get back

`RunResult`:

- `attached: bool` tells whether `meander.plan` was attached to the span.
- `plan: dict | None` is the attached plan (on success).
- `shape_errors: list[ShapeError]` lists shape errors with `path` / `code` /
  `message`, when the output failed the shape check.
- `error_state` is `None` on success; otherwise it is the run's failure exit
  (SDK error, abort, type mismatch). When it is set, nothing is ever attached.

So no `meander.plan` always means one of two things: a failed run (`error_state`)
or an output with a bad shape (`shape_errors`).
Both are in the result; nothing disappears silently.

## 5. Your own tracing / your own SDK (the core)

If you want to wire your own SDK (or use your own tracer), you drive the
SDK-neutral context manager yourself.
It hands you the fragment and the schema, and takes care of parsing,
the shape check, locking, and attaching:

```python
from meander_agent import meander_run

with meander_run(tracer=my_tracer) as run:
    # run.prompt_fragment : shape + discovery instructions for your SDK
    # run.output_schema   : JSON schema, if your SDK can do structured output
    output, error = call_your_llm(task, instructions=run.prompt_fragment,
                                  schema=run.output_schema)
    # output: the structured result (dict) OR a plain JSON string.
    # error_state: None on success, otherwise any detail (=> lock).
    result = run.finalize(output, error_state=error)
```

The core never passes prompts to the SDK itself and never reads SDK results;
that is your binding's job.
The bundled Claude and OpenAI bindings work the same way.

## 6. A deterministic plan without an LLM

If you build the plan yourself (tests, rule-based agents, an auth-free path),
you use the emitter directly:

```python
from meander_agent import attach_plan, validate_plan_shape

plan = {
    "plan_version": 2,
    "action": {
        "kind": "tool_call",
        "name": "record_answer",
        "description": "Record the answer for question Q-42",
        "target": {"entity": "Answer", "identity": {"id": "Q-42"}},
    },
    "facts": [
        {"entity": "Answer", "identity": {"id": "Q-42"},
         "property": "text", "value": "The Western Roman Empire fell in 476 CE.",
         "origin": {"kind": "tool", "ref": "lookup_answer"}},
    ],
    "relations": [
        {"relation": "supports",
         "from": {"entity": "Answer", "identity": {"id": "Q-42"}},
         "to":   {"entity": "Answer", "identity": {"id": "Q-7"}},
         "origin": {"kind": "agent"}},
    ],
    "policy_goal": {
        "relation": "may_record",
        "object": {"entity": "Outcome", "identity": {"outcome_id": "allowed"}},
    },
}

errors = validate_plan_shape(plan)            # pure shape check (empty = ok)
with client.tracer.start_as_current_span("agent.run") as span:
    res = attach_plan(span, plan)             # attaches if the shape is valid
    if not res.ok:
        print("not attached:", [e.as_dict() for e in res.errors])
client.shutdown()
```

## Public surface

| Symbol | Purpose |
|---|---|
| `init_meander(endpoint, source_key) -> MeanderClient` | set up transport (OTel + OTLP) |
| `MeanderClient.connect() / .tracer / .run(vocabulary=None) / .force_flush() / .shutdown()` | transport probe, tracer, core shortcut, export |
| `meander_agent.claude.run_with_plan[_async](task, *, tracer, vocabulary=None)` | Claude binding |
| `meander_agent.openai.run_with_plan[_async](task, *, tracer, vocabulary=None)` | OpenAI binding |
| `meander_run(tracer, vocabulary=None) -> Run` | SDK-neutral core context manager |
| `Run.prompt_fragment / .output_schema / .finalize(output, error_state)` | building blocks + processing |
| `attach_plan(span, plan) -> AttachResult` | shape check + attach |
| `validate_plan_shape(plan) -> list[ShapeError]` | pure shape check |
| `RunResult`, `AttachResult`, `ShapeError` | result / error types |
| `PLAN_ATTRIBUTE_KEY`, `DISCOVERY_ATTRIBUTE_KEY`, `ORIGIN_KINDS`, `PLAN_VERSION` | client constants |

## Development

```
pixi run test          # random order (pytest-randomly)
pixi run -- pytest -p no:randomly    # fixed order
```

The credential-gated live LLM tests (Claude/OpenAI) run where the keys are set,
and are skipped otherwise.
The deterministic suite always runs, with no mocks.
