Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 101 additions & 0 deletions python/agents/avp-ollama/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
# avp-ollama

AVP v0.1 agent backed by [Ollama](https://ollama.com).

**Scope:** intentionally minimal. This runner exists primarily to demonstrate
supervisor-orchestrated **execution-backend rescue** — see
`portofcontext/RESCUE_PLAN.md` and `spec/v0.1/trajectory.md` §7.3. It emits the
AVP prelude, drives a single (multi-turn-capable) Ollama chat loop, and
emits a conforming `agent_stopped`.

What it does **not** do (vs. `avp-claude-agent`):

- No tool calls. Ollama's tool-use surface is model-specific and out of scope
for the rescue demo.
- No managed-asset resolution (no `mcp_servers`, no `subagents`, no `skills`).
- No live SDK lifecycle bridging — we drive the chat loop directly.
- No streaming → fine-grained `text_emitted` events. We emit one `text_emitted`
per turn with the full assistant message.

## Failure injection

The runner honors a `RESCUE_FAIL_AT` env var, used by the rescue smoke test to
provoke a runner failure mid-trajectory:

| Value | Behavior |
|---|---|
| unset | Normal run, no injected failure. |
| `now` | Fail immediately after `agent_started`. |
| `turn:N` | Fail at the start of turn N (1-indexed). If the conversation converges before reaching turn N (common for no-tools prompts that complete in one turn), the failure fires right before `agent_stopped` instead — so the demo always gets a rescue scenario. Set `OLLAMA_FORCE_TURNS=M` (M ≥ N) to make the rescue fire from the realistic "before turn N starts" path. |
| `prob:0.5` | Fail at each turn with probability 0.5. |

## Multi-turn behavior (`OLLAMA_FORCE_TURNS`)

Ollama's non-streaming `/api/chat` returns `done: true` after every response,
so without help the translator exits after turn 1. For demos that need an
actual multi-turn trajectory:

| Value | Behavior |
|---|---|
| unset / `0` | Normal: exit when the model signals `done`. |
| `N` (≥1) | Run exactly N turns. Between turns the translator appends a synthetic user-role message (`OLLAMA_CONTINUATION_PROMPT`, default "Continue with the next step. Be brief.") to keep the model generating. |

This lets `RESCUE_FAIL_AT=turn:2` fire from the realistic "fail before turn 2
starts" code path on a genuinely multi-turn conversation, rather than the
short-prompt fall-through.

On injected failure the runner emits:

```
avp.error_occurred
data["avp.error.code"] = "execution_backend_failure"
data["avp.error.message"] = <description>
```

…and exits **without** emitting `agent_stopped`. The supervisor sees the
`execution_backend_failure` code and triggers a rescue
(`POST /api/runs/{run_id}/rescue` → re-dispatch on a fallback backend).

## Dispatch surface

`avp-ollama-runner` starts a FastAPI server on `OLLAMA_RUNNER_PORT`
(default `8081`). The supervisor's `LocalOllamaBackend` POSTs
`{run_id, environment, resolver?}` to `/` and the runner spawns a background
task to drive the run, returning `{call_id, status: "spawned"}` immediately.

Configuration env vars:

| Var | Purpose | Default |
|---|---|---|
| `OLLAMA_RUNNER_PORT` | FastAPI bind port | `8081` |
| `OLLAMA_HOST` | Ollama HTTP base | `http://localhost:11434` |
| `OLLAMA_MODEL_DEFAULT` | Used when the Commission `model` is empty/unknown | `llama3.2:3b` |
| `SUPERVISOR_BASE_URL` | Where events get posted | `http://localhost:5150` |
| `SUPERVISOR_PROXY_TOKEN` | Optional bearer for the supervisor | unset |
| `RESCUE_FAIL_AT` | Failure injection (see above) | unset |
| `OLLAMA_MAX_TURNS` | Safety cap | `8` |

## Quick start

```bash
# Terminal 1 — Ollama
ollama serve
ollama pull llama3.2:3b

# Terminal 2 — supervisor (with rescue enabled)
SUPERVISOR_RESCUE_ENABLED=1 \
SUPERVISOR_RESCUE_FALLBACK='local-ollama:modal-sandbox' \
OLLAMA_DISPATCH_URL=http://localhost:8081/ \
cargo loco start

# Terminal 3 — the ollama runner
uv run --package avp-ollama avp-ollama-runner

# Terminal 4 — dispatch a run that fails on turn 2
supervisor run dispatch \
--env demo --runner local-ollama \
--prompt "tell me a short story" \
...
```

See `worker/smoke_test_rescue.sh` (phase 1.4) for the end-to-end demo.
33 changes: 33 additions & 0 deletions python/agents/avp-ollama/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
name = "avp-ollama"
version = "0.1.0"
description = "AVP v0.1 agent backed by a local Ollama model. Minimal-scope runner used to demonstrate supervisor-orchestrated execution-backend rescue."
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.11"
dependencies = [
"avp>=0.1,<0.2",
"httpx>=0.27",
"fastapi[standard]>=0.115",
"uvicorn>=0.32",
]

[project.optional-dependencies]
dev = ["pytest>=7", "ruff>=0.6"]

[project.scripts]
avp-ollama = "avp_ollama.cli:main"
avp-ollama-runner = "avp_ollama.runner:main"

[project.urls]
Spec = "https://github.com/portofcontext/agent-voyager-project/tree/main/spec/v0.1"

[tool.hatch.build.targets.wheel]
packages = ["src/avp_ollama"]

[tool.pytest.ini_options]
testpaths = ["tests"]
13 changes: 13 additions & 0 deletions python/agents/avp-ollama/src/avp_ollama/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""AVP runner backed by a local Ollama model.

See the package README for scope. The public surface:

- `OllamaTranslator` — drives one run end-to-end against the supervisor.
- `SupervisorEventClient` — thin HTTP client for the supervisor's event API.
- `runner.app` — FastAPI dispatcher used by `LocalOllamaBackend`.
"""

from .supervisor_client import SupervisorEventClient
from .translator import OllamaTranslator, RescueFailAt

__all__ = ["OllamaTranslator", "RescueFailAt", "SupervisorEventClient"]
39 changes: 39 additions & 0 deletions python/agents/avp-ollama/src/avp_ollama/cli.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"""`avp-ollama run <run_id>` — drive a single run synchronously without
the FastAPI dispatch layer. Useful for manual testing and local debugging.

The supervisor must already have the Run row created; this just fetches
the config and runs the translator inline."""

from __future__ import annotations

import argparse
import logging
import sys

from .runner import _drive_run


def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="avp-ollama")
sub = parser.add_subparsers(dest="cmd", required=True)

p_run = sub.add_parser("run", help="Drive a run from a pre-created Run row.")
p_run.add_argument("run_id", help="The supervisor's run_id.")
p_run.add_argument(
"--verbose", "-v", action="store_true", help="Enable debug logging."
)

args = parser.parse_args(argv)
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
)

if args.cmd == "run":
_drive_run(args.run_id)
return 0
return 2


if __name__ == "__main__": # pragma: no cover
sys.exit(main())
101 changes: 101 additions & 0 deletions python/agents/avp-ollama/src/avp_ollama/runner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
"""FastAPI dispatcher for the avp-ollama runner.

Mirrors the shape `worker/modal_app.py::spawn_endpoint` uses, so the
supervisor's `LocalOllamaBackend` can talk to either backend without
caring which is on the other end.

Body: `{run_id, environment?, resolver?}`. The supervisor has already
created the Run row in Postgres; this endpoint just kicks off the
`OllamaTranslator` on a background thread and returns immediately.

Run as `avp-ollama-runner` (entry point). The Run config is fetched
from the supervisor on dispatch — the supervisor stores the
Commission alongside the row.
"""

from __future__ import annotations

import logging
import os
import threading
from typing import Any

import httpx
import uvicorn
from fastapi import FastAPI, HTTPException

from .supervisor_client import SupervisorEventClient
from .translator import OllamaTranslator, RescueFailAt

logger = logging.getLogger(__name__)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")

app = FastAPI(title="avp-ollama runner")


def _fetch_run_config(run_id: str) -> dict[str, Any]:
base = (os.environ.get("SUPERVISOR_BASE_URL") or "http://localhost:5150").rstrip("/")
headers: dict[str, str] = {}
if tok := os.environ.get("SUPERVISOR_PROXY_TOKEN"):
headers["Authorization"] = f"Bearer {tok}"
r = httpx.get(f"{base}/api/runs/{run_id}", headers=headers, timeout=30.0)
r.raise_for_status()
body = r.json()
config = body.get("config")
if not isinstance(config, dict):
raise ValueError(f"supervisor returned no config for {run_id}")
return config


def _drive_run(run_id: str) -> None:
"""Background thread target. Fetches the run config, drives the
translator. Exceptions are logged; the supervisor will surface a
rescue-able state via the `execution_backend_failure` event the
translator emits before re-raising."""
logger.info("driving run %s", run_id)
try:
config = _fetch_run_config(run_id)
except Exception:
logger.exception("failed to fetch run config for %s", run_id)
return
client = SupervisorEventClient()
try:
translator = OllamaTranslator(run_id=run_id, config=config, client=client)
outcome = translator.run()
logger.info("run %s finished: %s", run_id, outcome)
finally:
client.close()


@app.post("/")
def spawn(payload: dict[str, Any]) -> dict[str, Any]:
"""Supervisor-facing dispatch webhook. Matches `LocalOllamaBackend`'s
request body."""
run_id = payload.get("run_id")
if not isinstance(run_id, str) or not run_id:
raise HTTPException(status_code=400, detail="missing_run_id")
# Log injection-mode if set — useful when debugging a smoke test.
fail = RescueFailAt.from_env()
if fail.mode != "none":
logger.info(
"dispatch %s with RESCUE_FAIL_AT=%s (turn=%s, prob=%s)",
run_id, fail.mode, fail.turn, fail.probability,
)
thread = threading.Thread(target=_drive_run, args=(run_id,), daemon=True)
thread.start()
return {"run_id": run_id, "call_id": run_id, "status": "spawned"}


@app.get("/healthz")
def healthz() -> dict[str, str]:
return {"status": "ok"}


def main() -> None:
port = int(os.environ.get("OLLAMA_RUNNER_PORT") or 8081)
host = os.environ.get("OLLAMA_RUNNER_HOST") or "0.0.0.0"
uvicorn.run(app, host=host, port=port, log_level="info")


if __name__ == "__main__":
main()
77 changes: 77 additions & 0 deletions python/agents/avp-ollama/src/avp_ollama/supervisor_client.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
"""HTTP client for the supervisor's event-append API.

Minimal subset — `avp-ollama` only needs to POST events. State transitions
(running/completed/failed) are inferred by the supervisor from the
`agent_started` / `agent_stopped` events the translator emits.

Synchronous httpx; the translator runs on a worker thread launched by the
FastAPI dispatcher, so async would just complicate the call chain without
buying us anything for the rescue demo."""

from __future__ import annotations

import os
from typing import Any

import httpx


class SupervisorEventClient:
"""POSTs events to `{base_url}/api/runs/{run_id}/events`.

Pulls `SUPERVISOR_BASE_URL` and the optional `SUPERVISOR_PROXY_TOKEN`
from env when not given explicitly."""

def __init__(
self,
base_url: str | None = None,
*,
token: str | None = None,
timeout: float = 30.0,
) -> None:
self._base = (
base_url or os.environ.get("SUPERVISOR_BASE_URL") or "http://localhost:5150"
).rstrip("/")
headers: dict[str, str] = {}
tok = token if token is not None else os.environ.get("SUPERVISOR_PROXY_TOKEN")
if tok:
headers["Authorization"] = f"Bearer {tok}"
self._client = httpx.Client(timeout=timeout, headers=headers)

def close(self) -> None:
self._client.close()

def __enter__(self) -> "SupervisorEventClient":
return self

def __exit__(self, *exc: Any) -> None:
self.close()

def append_event(self, run_id: str, seq: int, event: dict[str, Any]) -> dict[str, Any]:
"""Append one CloudEvents-shaped event. Raises `httpx.HTTPStatusError`
on non-2xx (the translator catches and logs; a failed event post
should not abort the run)."""
r = self._client.post(
f"{self._base}/api/runs/{run_id}/events",
json={"seq": seq, "event": event},
)
r.raise_for_status()
return r.json()

def fetch_events(self, run_id: str) -> list[dict[str, Any]]:
"""Return every event the supervisor has for `run_id`, in
seq order. Used by the translator to discover the next free
seq on startup (which is non-zero when this run was rescued
and is being resumed by a fresh runner)."""
r = self._client.get(f"{self._base}/api/runs/{run_id}/events")
r.raise_for_status()
body = r.json()
return body if isinstance(body, list) else []

def next_seq(self, run_id: str) -> int:
"""Return `max(seq) + 1` over the run's existing events.
Zero for a fresh dispatch with no prior events."""
events = self.fetch_events(run_id)
if not events:
return 0
return max(int(e.get("seq", 0)) for e in events) + 1
Loading