Metadata-Version: 2.5
Name: spectroscope
Version: 0.1.0
Summary: The agent orchestrator you can watch: a graph runtime that writes its topology before the run, plus a dependency-free client for a spectro-server.
Project-URL: Homepage, https://spectroscope.ai
Project-URL: Repository, https://github.com/spectroscope/spectroscope-sdk-python
Project-URL: Issues, https://github.com/spectroscope/spectroscope-sdk-python/issues
Author-email: Christopher Ezell <chris@spectroscope.ai>
License-Expression: MIT
License-File: LICENSE
Keywords: agent,graph,langgraph,llm,observability,orchestration
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3 :: Only
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Software Development :: Libraries
Classifier: Topic :: System :: Monitoring
Classifier: Typing :: Typed
Requires-Python: >=3.10
Provides-Extra: dev
Requires-Dist: pytest>=7; extra == 'dev'
Description-Content-Type: text/markdown

# spectroscope — the agent orchestrator you can watch

A graph runtime whose **topology is written to disk before the first token
moves**, and a client that can name the server holding your runs by address
instead of assuming it is on this machine.

```sh
pip install spectroscope
```

Nothing comes with it. The engine is stdlib, and so is the client — `urllib`
rather than `requests` or `httpx`. That is a promise about your dependency tree,
not thrift.

---

## Where the engine came from

`src/spectroscope/graph/` was copied out of the spectroscope Python edition on
2026-08-11, byte-identical that day: 6 modules, 3594 lines, counted with
`wc -l src/spectroscope/graph/*.py`.

That is provenance, not an instruction. **Since 2026-08-12 this package owns the
engine** — edit it here, and the suite in this repository is what proves it.

There was a tool that diffed the two trees and failed the suite when they parted
company. It was withdrawn along with that decision, because it could no longer
return a pass: it compared against a sibling checkout that no public clone and
no `pip install` has ever had, and it reported that as "nothing was compared —
this is not a pass". A guard that can only skip reads like coverage and delivers
none. `src/spectroscope/graph/VENDORED.md` records what it did and what is
consequently no longer watched.

---

## Why a graph at all

An agent loop can only be reconstructed afterwards, out of its logs. A graph's
shape is fixed the moment `compile()` returns, so the machine can be **drawn
first and then lit up** as the run walks it. Everything here serves that: the
builder refuses a graph it cannot draw, the executor runs in supersteps so a run
replays the same way twice, and every node entry, exit and edge taken lands in a
JSONL artifact beside your session file.

```python
import asyncio
from operator import add
from typing import Annotated, TypedDict

from spectroscope import END, START, GraphArtifact, InMemorySaver, StateGraph


class State(TypedDict, total=False):
    frage: str
    treffer: Annotated[list[str], add]   # the reducer is read off the annotation
    antwort: str


def suchen(state): ...
def pruefen(state): ...
def entscheiden(state) -> str: ...


g = StateGraph(State)
g.add_node("suchen", suchen); g.add_node("pruefen", pruefen)
g.add_edge(START, "suchen"); g.add_edge("suchen", "pruefen")
g.add_conditional_edges("pruefen", entscheiden,
                        {"antworten": "antworten", "erweitern": "erweitern"})
compiled = g.compile(checkpointer=InMemorySaver(),
                     sink=GraphArtifact("lauf.graph.jsonl"))

final = asyncio.run(compiled.ainvoke({"frage": "wo"},
                                     {"configurable": {"thread_id": "t1"}}))
```

`sink=` and `state=` are the two additions to the LangGraph signature, and both
are keyword-only so nothing positional can drift into them. The runtime is
async: `ainvoke`, `astream`, `aget_state`. There is no sync façade, because a
fake one would have to spin its own event loop and would then deadlock inside
any caller that already has one.

### What lands in the file

`lauf.graph.jsonl` gets the topology at compile time, then one record per node
entry, exit and edge taken. It carries **keys, never values** — which is what
makes it safe to attach to a bug report.

Values are a second file and they are **off unless you ask**, in one line you
can find again:

```python
from spectroscope import StatePolicy, artifacts

compiled = g.compile(sink=artifacts("lauf.jsonl", state=StatePolicy.summary()),
                     state=StatePolicy.summary())
```

Four tiers — `off`, `summary`, `sample`, `full`. Measured against a real
eight-node CRAG turn, as a share of the run's own state volume: `off` 2.78 %,
`summary` 6.53 %, `sample` 16.99 %, `full` 103.40 %. A truncated value never
looks like a complete one: every ceiling that fires replaces the value with a
self-describing marker carrying the true size and the limit that hit.

A library that starts writing your document corpus to disk because it was
upgraded is a data incident, not a feature. That is why the default is `off` and
why it stays there.

### Reading it back

`examples/graphview/graphview.html` is a single file with no CDN, no build and
no network calls. Open it in a browser, drop a `.graph.jsonl` and its
`.state.jsonl` on it, and the run replays over the drawing. Two things it does
on purpose that a trace viewer does not: the **path not taken stays in the
picture** and only recedes, and **back edges bow visibly out of the line** so a
correction loop reads as a loop. Right and middle mouse buttons pan, left
selects.

Two runnable demos write artifacts for it into `examples/out/`:

```sh
./.venv/bin/python examples/graph_topology_demo.py   # the drawing and the walk
./.venv/bin/python examples/graph_payload_demo.py    # the four value tiers, side by side
```

The viewer and both demos came from the Python edition's `spectro/examples/` on
2026-08-11 and are maintained here now.

---

## Running an existing LangGraph application on this engine

`compat/` is a drop-in `langgraph` namespace. Put the directory on the path and
an application keeps its imports exactly as written:

```python
from langgraph.graph import END, START, StateGraph
from langgraph.checkpoint.memory import InMemorySaver
from langgraph.config import get_stream_writer
```

Five files, each one import line, no adapters — `tests/test_compat_imports.py`
asserts identity (`is`), so a shim that wrapped anything would fail. It is never
published to PyPI under that name; `langgraph` belongs to LangChain, Inc. Read
`compat/README.md` before switching: the shim is a *regular* package and
therefore shadows the whole installed namespace, `langgraph.prebuilt` and
`langgraph.types` included.

**What this is not:** a LangGraph reimplementation. `Send`, `Command`,
`interrupt()`, `Store`, subgraphs, retry policies, `add_messages` and the
platform server API are absent, each with a reason on the record. An application
that touches one fails at import — loudly, at start, which is the intended
failure mode.

The evidence, such as it is: one real application's suite (130 tests) runs green
on this engine across three rounds of changes, and 98 behaviours were compared
against LangGraph 1.2.9 with 89 identical and the rest written down as
divergences. Both measurements were taken in the spectroscope Python edition on
2026-08-10 and are quoted here rather than reproduced — nothing in this package
re-runs them.

---

## The SDK half: a server named by address

```python
from spectroscope import SpectroscopeClient

server = SpectroscopeClient("box.local:8080")
print(server.probe().summary)

for session in server.sessions():
    print(session["id"])
```

Accepted forms: `http://box:8080`, `https://box:8443`, `box:8080`, `[::1]:8080`,
`localhost` (port defaults to 8080).

### Read this before pointing it off-box

**`spectro-server` fences its entire REST surface to a local origin.** One
servlet filter in the server refuses every `/api` path unless the TCP peer is
loopback **and** the `Host` header names localhost. Exactly one path is left
open on purpose:

| Endpoints | Fence | Works against a remote address? |
|---|---|---|
| `GET /api/health` | `none` | **yes** |
| the other 58 | `host` and stricter | **no** — blank 404 |

Fence classes, in the server's own vocabulary, from the endpoint collection
published with the server (measured 2026-08-04, 59 endpoints, probed with curl
against a live server rather than read off the controllers):

- **`host`** — loopback peer plus a localhost `Host`. This is the floor under
  everything else.
- **`host+origin`** — plus: an `Origin` header, if present, must be loopback. A
  REST client sends none, which counts as safe.
- **`host+origin+json`**, **`host+json`** — plus `Content-Type: application/json`,
  or 415.
- **`host+json+optin`** — plus the server must run with `SPECTRO_ALLOW_SPAWN`.
  `POST /api/fleet/nodes` only. Disabled and fenced-out both answer 404: there
  is no enablement oracle.

Every refusal is a **blank 404, never a 403**, so a refused caller cannot
fingerprint the server. The cost of that lands here: a fenced-out call looks
exactly like a session that is not there. So the client does three things rather
than guess.

**It names the fence when the address cannot pass it.**

```python
server = SpectroscopeClient("box.local:8080")
server.sessions()
# LocalFenceError: 404 /api/sessions on http://box.local:8080: every /api path
# except /api/health is fenced to a loopback peer carrying a localhost Host
# header (fence class 'host', table measured 2026-08-04), and 'box.local' is not
# one of the names the server accepts. This endpoint has no 404 of its own, so
# the fence is what answered. Open a tunnel:
#     ssh -N -L 8080:localhost:8080 <user>@box.local
#     then point the client at http://localhost:8080
```

`LocalFenceError` subclasses `NotFound`, so code that already handles a 404 keeps
working.

**It admits when it is inferring.** `error.proven` is `True` only for
`GET /api/sessions` and `GET /api/fleet`, whose handlers have no 404 of their own
— there a 404 *must* be the filter. Everywhere else the fence is the likely cause
and the exception says so.

**It measures instead of arguing.** `probe()` sends two requests, `/api/health`
(open, so it separates "unreachable" from "refused") and `/api/sessions` (cannot
miss on its own), and returns a `FenceReport` with `reachable`, `fenced` and a
one-line `summary`. Neither request writes anything.

### How to actually reach a remote server

An **SSH local forward**, and nothing else:

```sh
ssh -N -L 8080:localhost:8080 you@box.local
# then
SpectroscopeClient("localhost:8080")
```

It satisfies both halves of the fence without weakening the server: the tunnel's
far end dials 127.0.0.1, so the peer is loopback, and your URL says `localhost`,
so the `Host` is right.

**There is no `Host` override in this client and there will not be one.** Forging
the header cannot work — the peer half is decided by routing, and no header moves
a packet's source address. Offering the knob would only teach a workaround that
fails.

If an operator opens the port to a network instead, know what that buys the
network: **spectro-server has no authentication at all.** The fence *is* the
access model. An open port is unauthenticated access to your sessions, your
settings, the API-key save endpoint and — with `SPECTRO_ALLOW_SPAWN` — process
spawning. If you front it with a proxy that adds auth, pass its header through:

```python
SpectroscopeClient("box.local:443", headers={"Authorization": "Bearer …"})
```

### What the client does not speak

The two WebSocket endpoints, `/ws` (the run stream) and `/ws/shell` (the PTY).
They are origin-checked at handshake time and are not REST. A dependency-free
client has no WebSocket implementation, and a half-working stream on the surface
would be worse than an absent one.

---

## Development

```sh
git clone … && cd spectroscope-sdk-python
python3 -m venv .venv && ./.venv/bin/pip install -e ".[dev]"
./.venv/bin/python -m pytest -q
```

A `conftest.py` puts `src/` on the path, so a plain `python -m pytest -q` in a
fresh clone runs the suite without an install. It *prepends*: a `spectroscope`
installed elsewhere in the same interpreter would otherwise answer first and the
suite would quietly test the wrong engine.

**Nothing runs this suite but a person typing the command.** There is one
workflow, `.github/workflows/release.yml`, and it builds and publishes on a
version tag — it does not run the tests. So every number here was measured by
hand and carries the date and the command that produced it, because a number
nobody re-measures rots the next time the tree is touched.

On 2026-08-12, `python -m pytest -q` on 3.14.6:

```
515 collected · 513 passed · 2 skipped
```

The two skips are deliberate. They resolve a reducer schema out of a real
production application's own file, which is not public, so they run only when
you point them at one:

```sh
SPECTROSCOPE_REFERENCE_STATE=/path/to/backend/graph/state.py python -m pytest -q
```

Five interpreters were run on 2026-08-11, when the suite stood at 480 cases:
3.10.20, 3.11.15, 3.12.13, 3.13.13 and 3.14.6, 480 passing on each. Only 3.14.6
has been re-run since, so the other four attest to the tree as it was that day
and not to this one.

The editable install was checked on 2026-08-11 rather than assumed: a fresh
venv, `pip install -e .`, then an import of `spectroscope` from a *different*
working directory — which is what proves the install rather than the path insert
in `conftest.py`.

### Known gaps, stated rather than implied

- **L3, the LLM wire.** A third artifact, `<stem>.llm.jsonl`, recording the real
  provider exchange at the socket, is specified and has a test module written
  against it. The module it tests does not exist. **Nothing here emits
  `.llm.jsonl`.**
- **Python ceiling, not floor.** `requires-python = ">=3.10"` is not inherited
  on faith — the floor was checked by running the suite on 3.10.20, 3.11.15,
  3.12.13 and 3.13.13 as well as 3.14.6 (2026-08-11). The open top end is not
  covered: `>=3.10` also promises 3.15 and later, and no one has run those.
  Nothing re-checks any of this automatically.
- **No engine drift check.** See "Where the engine came from". Nothing notices
  if this engine and the Python edition's disagree about the `.graph.jsonl` and
  `.state.jsonl` format they both write.

## License

MIT. See `LICENSE`.
