Metadata-Version: 2.5
Name: swisper-agent-sdk
Version: 0.1.0a2
Summary: The published contract a partner builds a Swisper domain agent against
Project-URL: Homepage, https://github.com/fintama/helvetiq
Project-URL: Repository, https://github.com/fintama/helvetiq/tree/main/apps/backend/packages/swisper-agent-sdk
License: MIT
Keywords: a2a,agent,domain-agent,sdk,swisper
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: jsonpath-ng>=1.7
Requires-Dist: langchain-core>=0.1
Requires-Dist: langgraph>=0.2
Requires-Dist: pydantic>=2.0
Requires-Dist: pyyaml>=6.0
Provides-Extra: dev
Requires-Dist: mypy>=1.8.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.2.2; extra == 'dev'
Description-Content-Type: text/markdown

# swisper-agent-sdk

Build a Swisper domain agent.

This package is self-contained. You do not need the Swisper backend, a database,
or network access to build an agent and test it.

## Install

Requires **Python 3.11 or newer**.

```bash
python3 -m venv .venv
./.venv/bin/pip install swisper-agent-sdk
```

Use the virtual environment's interpreter for everything below. Typing `python3`
rather than `./.venv/bin/python` is the most common way to get a
`ModuleNotFoundError` that looks like a broken package and is not.

> **Five declared dependencies pull in more than five packages.** This package
> declares `pydantic`, `langgraph`, `jsonpath-ng`, `pyyaml` and `langchain-core` —
> `langgraph` alone brings its own dependency tree with it (`langsmith`, `httpx`,
> `orjson`, `ormsgpack`, `zstandard` and more). Measured in a clean install: **36
> packages**, not five. None of that is a defect — every one of them is something
> `langgraph` genuinely needs — but a partner watching 36 packages scroll by after
> being told about five deserves to have been told first.

### From a directory, instead

Handed the package as a tarball or a source checkout rather than installing from
PyPI? Point `pip` at the directory instead of the package name — everything else
on this page works exactly the same either way:

```bash
python3 -m venv .venv
./.venv/bin/pip install -e ./swisper-agent-sdk
```

## Your first agent

This is `swisper_agent_sdk/examples/twelve_line_agent.py`, complete and
unabridged. It ships inside the package itself, so installing it is the only step
— there is nothing separate to fetch:

```python
from swisper_agent_sdk import Agent, Delegation, Reply

agent = Agent(
    name="echo_agent",
    description="Repeats the task back. The smallest thing that is still an agent.",
)


@agent.handler
async def handle(d: Delegation) -> Reply:
    if not d.task:
        return Reply.ask("What would you like me to do?")
    return Reply(text=f"You asked me to: {d.task}")
```

Run it — the same command whether you installed from PyPI or from a directory:

```console
$ ./.venv/bin/python -m swisper_agent_sdk.examples.twelve_line_agent
You asked me to: book a train to Bern
```

`d.task` is what the user asked for. Returning a `Reply` answers them;
returning `Reply.ask(...)` puts a question to them and pauses the turn.

## Running a turn yourself

`harness` runs a real turn against your agent with **no core, no database and
no network**:

```python
import asyncio
from swisper_agent_sdk import harness
from my_agent import agent

result = asyncio.run(harness.turn(agent, "book a train to Bern"))
print(result.text)
```

### Check your install

Needs the directory install above — `tests/` ships in a source checkout, not in
the plain PyPI wheel:

```console
$ cd swisper-agent-sdk
$ ../.venv/bin/pip install -e ".[dev]"
$ ../.venv/bin/python -m pytest
```

That runs a handful of smoke tests: the package imports, importing it leaves your
logging alone, an agent answers a turn offline, and every bundled example runs and
prints something. If those pass, the SDK works on your machine.

> **Run `pytest` from inside this directory.** The package's own
> `asyncio_mode = "auto"` setting lives in its `pyproject.toml`, and pytest only
> picks it up when invoked from here. Run it from the parent directory and every
> `async def` test fails — **including correct ones** — which looks like a broken
> SDK and is not.

`[dev]` is what installs `pytest` and `pytest-asyncio`. The base install
deliberately does not: it is the runtime, not the toolchain.

### Writing your own tests

`turn()` returns a `TurnResult` with `.text`, `.status`, `.resume_with`, `.cards`,
`.said`, and two helpers for assertions — `.said_something_about(...)` and
`.asked_for(...)`:

```python
async def test_it_answers():
    r = await harness.turn(agent, "weather in Bern tomorrow")
    assert r.said_something_about("Bern")


async def test_it_can_ask_a_question():
    r = await harness.turn(agent, "book me a train")
    assert r.asked_for("departure time")
    r = await harness.answer(r, "09:00")     # resumes the paused turn
```

`harness.answer(...)` resumes a turn your agent paused with `Reply.ask(...)`.
Inside the handler, `d.answer` is what the user replied and `d.resumed` is
`True`.

> **What a green harness run does and does not prove.** The harness fakes core,
> the capability endpoint and the token. A green harness run proves your agent's
> logic, not that the capability works against a live core. Those are different
> claims and only the first one is tested here.

## Saying things, and sending cards

Two ways to put something in front of the user besides your final answer.

**`await d.say("…")`** narrates a deterministic moment — typically just before a slow
call, where waiting for the model to decide to mention it is the wrong behaviour:

```python
await d.say("Checking which trains actually run at that hour…")
```

**`await d.card("swi-transport", payload)`** emits a card. **The types your agent may
emit are declared on the agent**, and an undeclared type raises
`UndeclaredCardTypeError` — never a silent drop:

```python
agent = Agent(name="…", description="…", cards=["swi-transport"])

await d.card("swi-transport", {"from": "Zurich", "to": "Bern"})   # fine
await d.card("swi-invented", {...})                               # UndeclaredCardTypeError
```

The declared list is also what the prompt tells the model about, so it cannot invent a
card type that has no renderer. Both are visible in tests as `result.said` and
`result.cards`.

## A fuller example

`swisper_agent_sdk/examples/meal_planning_agent.py` is the one to read once the
twelve-line agent makes sense. It is a working agent that recalls what it knows
about you, asks a question when it does not know enough, resumes on your answer,
and emits a card:

```console
$ ./.venv/bin/python -m swisper_agent_sdk.examples.meal_planning_agent
[turn 1] status=waiting_for_input text="I don't have any dietary restrictions or allergies on file for you — could you tell me what I should plan around?"
[turn 2] status=complete text="Here's a plan built around: I'm vegetarian and allergic to peanuts.."
cards emitted: ['meal_plan']
```

It also shows how to reach data you are entitled to. Declare what you need on the
agent and read it from the delegation:

```python
agent = Agent(..., inputs=("fact_lookup_service",))

# in the handler:
facts = await d.fact_lookup_service.get_by_type("ALLERGY")
```

**You only receive what you declare.** Anything you did not ask for is absent from
your delegation, not present-and-empty.

> 🔴 **`fact_lookup_service` works in-process and is withheld over the wire.** It is a
> live service object, so it cannot be serialised to a remotely deployed agent — core
> drops it from the envelope unconditionally. **The example above runs green under the
> harness and returns nothing once your agent is deployed remotely, with no error.**
>
> This is stated here because it is the one place where following our own documentation
> produces a silent failure. Facts are meant to reach a remote agent **pre-loaded onto
> the envelope**, declared in the agent's contract — **that path is seamed but not yet
> wired, so a remote agent currently receives no pre-loaded facts.** If you are building
> for remote deployment, do not design around `fact_lookup_service` yet; talk to us.

## Two ways to write an agent, one runtime

`@agent.handler` is not a simplified mode you outgrow. **A handler is compiled
into a one-node graph**, so it runs through exactly the same machinery as an
agent you author as a graph yourself:

```python
agent = Agent(name="…", description="…", graph=my_compiled_graph)
```

Start with a handler. Move to `graph=` when you need more than one node. You are
not choosing a path you have to undo later.

## Telling the model how to behave

> 🔴 **No planner ships in this package.** `instructions=`, `routing=`,
> `narration_style=`, `tool_guidance=`, `prompt=` and `tools=` are stored on the
> `Agent` and are **not read when your agent runs**. Nothing here calls a model.
>
> To use a composed prompt today, call **`agent.compose_prompt(...)` yourself** and
> drive your own model with it — see
> [`examples/bring_your_own_model_agent.py`](src/swisper_agent_sdk/examples/bring_your_own_model_agent.py).
> A handler-mode agent decides everything in Python.
>
> **Everything in this section describes what `compose_prompt()` returns, not what
> the runtime does with it.**

Most agents need one thing — `instructions=`:

```python
agent = Agent(
    name="weather_agent",
    description="Answers weather and forecast questions.",
    instructions="""
    You answer questions about weather and forecasts.
    Prefer official meteorological sources over aggregators.
    Never speculate beyond seven days — say you don't know instead.
    """,
)
```

**Your `instructions` reach the model verbatim.** We never rewrite them, and they
are not a template: braces inside them are literal text, so you cannot
accidentally interpolate one of our internal variables.

The SDK composes the rest of the planner prompt around them — the planning loop,
how to ask a question and resume, how to narrate, and a context block carrying
the user's language, locale, timezone, today's date and their temporal context.

**That context block is why this wrapper exists.** Omit the language instruction
and you answer a German user in English. Omit the locale rules and dates come out
American. Omit temporal context and "tomorrow" is the wrong day. None of these
fail a test you would think to write, and all of them reach a user.

If you want to replace one composed section rather than all of it, pass
`narration_style=`, `tool_guidance=` or `routing=`. If you want full control,
pass `prompt=` — and `swisper_agent_sdk.prompts.scaffolding()` is importable, so
you can still assemble the parts you did not want to write yourself.

📖 **`PROMPTING.md`** has the full map of what gets composed, plus two rules that
are not general prompting advice — they are things we got wrong in production, in
ways that reached users. Read it before you write your second agent.

It ships inside the installed package (next to `examples/`), so it is on your
disk the moment `pip install swisper-agent-sdk` finishes. Find it with:

```console
$ ./.venv/bin/python -c "import swisper_agent_sdk, pathlib; print(pathlib.Path(swisper_agent_sdk.__file__).parent / 'PROMPTING.md')"
```

Working from a source checkout instead? It is at
[`src/swisper_agent_sdk/PROMPTING.md`](src/swisper_agent_sdk/PROMPTING.md).

## Shipping your agent

`agent.contract.yaml` is your `Agent(...)` declaration as a file — identity,
inputs, outputs, capabilities and cards — so core, or a teammate, can see what
your agent promises without reading your source. It round-trips both ways:

```python
from swisper_agent_sdk import AgentContract, load_agent

# emit it once your agent is built
AgentContract.from_agent(agent).write("agent.contract.yaml")

# read it back into a working agent — the file carries the declaration, never
# code, so you re-attach your graph/handler behaviour by hand
agent = load_agent(
    "agent.contract.yaml", name="my_agent", description="…", graph=my_graph
)
```

**No file at that path?** `load_agent(...)` builds an agent exactly as it would
without a contract at all — every `DomainAgentInput` field except
`fact_lookup_service` reachable, unfiltered (`default_inputs()`), no declared
outputs/capabilities/cards. Nothing regresses for an agent that has not adopted
the file yet.

> 🔴 **`fact_lookup_service` can never be declared in `agent.contract.yaml`.**
> It is a live service object, not data — core withholds it from the wire
> unconditionally, for every agent, with no per-agent override. Declaring it
> here raises `WireUnserialisableInputDeclaredError`, on both the write and the
> read path. See the `fact_lookup_service` note above: it works for an
> in-process agent (`examples/meal_planning_agent.py`), never for one declared
> to run remotely.

## What this package deliberately does not give you

Said up front, because discovering it by hitting a wall is worse:

- **No `swisper.*` imports.** The SDK never reaches into the Swisper backend, and
  a build check enforces that on both `src/` and `examples/`. If something you
  need is missing here, that is our gap to close — tell us rather than working
  around it.
- **No database access.** Your agent never opens a database connection. Data you
  are entitled to arrives through a capability call that core authorises for that
  turn.
- **Five declared dependencies**, deliberately: `pydantic`, `langgraph`,
  `jsonpath-ng`, `pyyaml` and `langchain-core`. A test asserts that every import
  is declared and every declaration is imported — not a count — so a new one
  cannot arrive as a side effect of a convenient import, undeclared. (See the
  install-time note above: `langgraph` alone brings a much larger dependency
  tree with it — the count above is what *we* declare, not what actually
  installs.)
- **The SDK does not configure logging.** Importing it leaves your root logger
  untouched. Call `swisper_agent_sdk.correlation.configure_logging()` yourself if
  you want ours.
