diff --git a/CHANGELOG.md b/CHANGELOG.md index c4ad43f..b61260f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Ollama integration ([#20](https://github.com/ARPAHLS/aura/issues/20))** — `integrations/ollama/llama_loop.py` stdlib HTTP body loop; README; mocked HTTP test; docs index distinguishes Ollama-only vs Ollama+Skillware paths. + ### Changed - **Verified operator identity ([#55](https://github.com/ARPAHLS/aura/issues/55))** — optional identity adapters (manual, mock, OIDC, Auth0); `identity.bound` spine event; `ids.operator` on all event trailers; export redaction; `aura identity show`; profile `types` with `role: identity`. diff --git a/docs/integrations/README.md b/docs/integrations/README.md index 04983a3..b687037 100644 --- a/docs/integrations/README.md +++ b/docs/integrations/README.md @@ -9,7 +9,8 @@ Attach AURA to your stack — models, tool runtimes, frameworks, sandboxes. | **Overview** | this page | Start here to find your stack | | **Skillware** | [`integrations/skillware/`](../../integrations/skillware/) | Reference ToolHost adapter; `[skillware]` extra | | **Operator identity** | [`integrations/identity/`](../../integrations/identity/) | Optional OIDC/Auth0/manual/mock adapters; `[identity]` extra | -| **Ollama (local)** | [`integrations/skillware/ollama_skill_loop.py`](../../integrations/skillware/ollama_skill_loop.py) | Dev default: `llama3.2:1b` via `.env` | +| **Ollama (local)** | [`integrations/ollama/`](../../integrations/ollama/) | Dev default: `llama3.2:1b` via `.env`; stdlib HTTP | +| **Ollama + Skillware** | [`integrations/skillware/ollama_skill_loop.py`](../../integrations/skillware/ollama_skill_loop.py) | Local model body with Skillware egress | | **OpenAI (ChatGPT)** | [`integrations/openai/`](../../integrations/openai/) | Body loop + Skillware egress; `[openai]` extra | | **Anthropic (Claude)** | [`integrations/anthropic/`](../../integrations/anthropic/) | Body loop + Skillware egress; `[anthropic]` extra | | **Google Gemini** | [`integrations/google/`](../../integrations/google/) | Body loop + Skillware egress; `[google]` extra | diff --git a/integrations/ollama/README.md b/integrations/ollama/README.md new file mode 100644 index 0000000..7ada831 --- /dev/null +++ b/integrations/ollama/README.md @@ -0,0 +1,38 @@ +# Ollama + AURA + +Run a local Ollama model as the **body** inside an AURA session. + +This example uses only Python stdlib HTTP and the shared stdlib `.env` loader in +`integrations/_shared/env.py`; it does not require `python-dotenv` or the Ollama +Python package. + +## Setup + +```powershell +ollama pull llama3.2:1b +copy .env.example .env +``` + +Set in `.env`: + +``` +OLLAMA_BASE_URL=http://127.0.0.1:11434 +OLLAMA_MODEL=llama3.2:1b +``` + +`AURA_HOME` is optional and controls where AURA writes local session data. + +Cloud body loops use the same `.env` pattern: + +- `OPENAI_API_KEY`, `OPENAI_MODEL` in [`../openai/`](../openai/) +- `ANTHROPIC_API_KEY`, `ANTHROPIC_MODEL` in [`../anthropic/`](../anthropic/) +- `GOOGLE_API_KEY`, `GEMINI_MODEL` in [`../google/`](../google/) + +## Run + +```powershell +python integrations/ollama/llama_loop.py +``` + +The script opens `with ag.session()`, emits `turn.start`, calls Ollama +`/api/chat`, emits `model.call`, then emits `turn.end`. diff --git a/integrations/ollama/llama_loop.py b/integrations/ollama/llama_loop.py new file mode 100644 index 0000000..c7f6e57 --- /dev/null +++ b/integrations/ollama/llama_loop.py @@ -0,0 +1,81 @@ +#!/usr/bin/env python3 +"""Minimal Ollama body loop under an AURA session.""" + +from __future__ import annotations + +import json +import os +import sys +import urllib.request +from pathlib import Path +from typing import Any + +_REPO = Path(__file__).resolve().parents[2] +if str(_REPO) not in sys.path: + sys.path.insert(0, str(_REPO)) + +from integrations._shared.env import load_dotenv # noqa: E402 + +load_dotenv(_REPO) + +from aura import agent, configure # noqa: E402 + + +def _ollama_chat( + base_url: str, + model: str, + messages: list[dict[str, str]], + *, + timeout: int = 30, +) -> str: + url = f"{base_url.rstrip('/')}/api/chat" + body = json.dumps({"model": model, "messages": messages, "stream": False}).encode("utf-8") + request = urllib.request.Request( + url, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(request, timeout=timeout) as response: + payload: dict[str, Any] = json.loads(response.read().decode("utf-8")) + return str(payload.get("message", {}).get("content", "")) + + +def run_loop(prompt: str) -> dict[str, Any]: + base_url = os.environ.get("OLLAMA_BASE_URL", "http://127.0.0.1:11434") + model = os.environ.get("OLLAMA_MODEL", "llama3.2:1b") + configure() + + ag = agent( + "ollama-llama-loop", + purpose="Ollama body loop under AURA audit", + ) + with ag.session(mode="script") as run: + run.emit("turn.start", {"input": prompt, "provider": "ollama", "model": model}) + output = _ollama_chat( + base_url, + model, + [ + {"role": "system", "content": "Answer briefly."}, + {"role": "user", "content": prompt}, + ], + ) + run.emit("model.call", {"provider": "ollama", "model": model, "output": output[:500]}) + run.emit("turn.end", {"output": output}) + + return { + "session_id": run.session_id, + "provider": "ollama", + "model": model, + "output": output, + "exports": run.exports, + } + + +def main() -> None: + prompt = "Say hello from AURA." + print(json.dumps(run_loop(prompt), indent=2, default=str)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_ollama_loop.py b/tests/test_ollama_loop.py new file mode 100644 index 0000000..02334fb --- /dev/null +++ b/tests/test_ollama_loop.py @@ -0,0 +1,49 @@ +"""Ollama loop tests with mocked HTTP.""" + +from __future__ import annotations + +import json +from pathlib import Path + +from integrations.ollama import llama_loop + + +class _MockResponse: + status = 200 + + def __enter__(self): + return self + + def __exit__(self, exc_type, exc, tb): + return False + + def read(self) -> bytes: + return json.dumps({"message": {"content": "mocked llama reply"}}).encode("utf-8") + + +def test_ollama_loop_emits_model_call_without_network(monkeypatch, aura_home): + seen = {} + + def fake_urlopen(request, timeout): + seen["url"] = request.full_url + seen["timeout"] = timeout + seen["body"] = json.loads(request.data.decode("utf-8")) + return _MockResponse() + + monkeypatch.setenv("OLLAMA_BASE_URL", "http://ollama.test") + monkeypatch.setenv("OLLAMA_MODEL", "llama3.2:1b") + monkeypatch.setattr(llama_loop.urllib.request, "urlopen", fake_urlopen) + + result = llama_loop.run_loop("hello") + + assert result["output"] == "mocked llama reply" + assert result["model"] == "llama3.2:1b" + assert seen["url"] == "http://ollama.test/api/chat" + assert seen["body"]["stream"] is False + assert seen["body"]["model"] == "llama3.2:1b" + assert [m["role"] for m in seen["body"]["messages"]] == ["system", "user"] + + jsonl = Path(result["exports"]["jsonl"]) + kinds = [json.loads(line)["kind"] for line in jsonl.read_text(encoding="utf-8").splitlines()] + assert "model.call" in kinds + assert kinds.index("turn.start") < kinds.index("model.call") < kinds.index("turn.end")