diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8cca724..e888050 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,7 +21,7 @@ jobs: - name: Install dependencies run: | python -m pip install --upgrade pip - pip install -e ".[dev,rich]" + pip install -e ".[dev,all]" - name: Run tests run: | python -m pytest tests/ -v diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md deleted file mode 100644 index 00dd687..0000000 --- a/ARCHITECTURE.md +++ /dev/null @@ -1,115 +0,0 @@ -# SOLVENT — Architecture - -**SOLVENT is an AI agent that operates as a profitable, self-funding business.** -It sells research briefs, collects payment through Stripe, and spends its own -revenue to provision the compute, data, and SaaS it needs to do the work — and -it refuses any job that doesn't clear a margin. The result is a fully automated -company with its own balance sheet that grows on its own. - -## The one-sentence pitch -An agent you can hand a Stripe key and walk away from: it earns, it pays its own -bills, it never works at a loss, and it can't spend a dollar outside policy. - -## Why it matters -Most agents can spend money; few can run as a business. SOLVENT closes the full -loop with **economic self-awareness** — it maintains a treasury, prices against its -own unit costs, and gates every action on projected profit. That's the difference -between a demo bot and a business. - -## The money loop (per job) - -``` - inbound job - │ - ▼ - ┌─────────────┐ margin < floor? ┌───────────┐ - │ MARGIN GATE│ ─────────────────▶ │ DECLINE │ - │ (pricing) │ └───────────┘ - └─────┬───────┘ accept - ▼ - ┌─────────────┐ EARN - │ STRIPE │ ── Checkout Session → webhook (primary) ──▶ + revenue - └─────┬───────┘ idempotent stages in SQLite - ▼ - ┌─────────────┐ FULFIL - │ NEMOTRON │ ── bounded tool-calling research loop ──▶ actual COGS - └─────┬───────┘ - ▼ - ┌─────────────┐ DELIVER - │ HOSTED+SMTP│ ── signed brief URL + optional email - └─────┬───────┘ - ▼ - ┌─────────────┐ SPEND (each payment screened first) - │ GUARDRAILS │ ── NemoClaw policy: allowlist, caps, reserve, ROI - │ → STRIPE │ ── Issuing virtual card (test) or simulated spend ──▶ − expense - └─────┬───────┘ - ▼ - BOOK P&L ──▶ treasury updated, dashboard refreshed -``` - -Revenue is always collected **before** cost is incurred, and no single payment -can violate policy — the business is safe by construction and profitable by rule. - -## How the sponsor stack maps in - -| Layer | Sponsor tech | Where it lives in the code | -|---|---|---| -| **Reasoning / the analyst** | **NVIDIA Nemotron** (Llama-3.1-Nemotron-Ultra) via the OpenAI-compatible endpoint | `solvent/nemotron.py` | -| **Spend safety** | **NVIDIA NemoClaw**-style policy sandbox — every payment screened before it executes | `solvent/guardrails.py` | -| **Earn + Spend** | **Stripe** — Checkout Sessions, webhooks, idempotency keys; Issuing for outbound spend (test mode) | `solvent/stripe_client.py` | -| **Agent orchestration** | Idempotent **stage machine** + bounded Nemotron tool loop | `solvent/stages.py`, `solvent/tools.py`, `solvent/agent.py` | -| **Async processing** | SQLite queue + worker resume | `solvent/worker.py`, `solvent/queue.py` | -| **Delivery** | Hosted briefs + SMTP/outbox | `solvent/delivery.py`, `solvent/server.py` | -| **Conversational surface** | OpenClaw gateway + Hermes tools/memory | `solvent/gateway.py`, `solvent/chat.py`, `solvent/memory.py`, `solvent/hermes_tools.py` | -| **Agent identity** | SOUL/BRAIN/AGENTS workspace (Hermes + OpenClaw) | `solvent/workspace.py`, `.solvent/workspace/` | -| **Telegram** | Long-poll bot + DM pairing | `solvent/channels/telegram.py`, `solvent/pairing.py` | -| **Live dashboard** | SSE chat + treasury | `solvent/server.py`, `solvent/event_hub.py`, `solvent/dashboard_chat.py` | -| **Cross-process notify** | Worker → serve chat outbox | `solvent/notifications.py` | -| **Auto-improvement** | Metrics-driven tuning | `solvent/improver.py` | -| **Observability** | JSON logs + Stripe reconciliation | `solvent/observability.py`, `solvent/reconcile.py` | -| **Economic memory** | the treasury / ledger that makes it a business | `solvent/treasury.py`, `solvent/pricing.py` | - -## Key design choices - -- **Viability:** the margin gate (`pricing.py`) means SOLVENT is *structurally* - incapable of unprofitable work. In the demo it earns $223, spends $13.35, and - books a 94% margin — and declines the one job that doesn't pencil out. -- **Safety:** `guardrails.py` enforces a vendor allowlist, a per-transaction cap, - a rolling 24h budget, a minimum cash reserve, and a no-negative-ROI rule. This - is the answer to "would you actually give an agent a payment credential?" -- **Runs anywhere:** with no API keys it runs on deterministic offline stubs, so - the demo always works. Add `NVIDIA_API_KEY` for live Nemotron and a Stripe - **test** key (`sk_test_...`) for real Payment Links. The client refuses live - Stripe keys outright. In test mode, payment is verified via Checkout Session - polling (or optional webhooks) before fulfilment; refunds use the PaymentIntent - id (`pi_...`). Product/Price catalog is cached locally; Issuing provides - capped virtual cards when enabled on the test account. - -## Files -``` -solvent/ - agent.py thin orchestrator delegating to stages - stages.py idempotent stage machine (quote→paid→fulfill→deliver→spend) - worker.py async job processor + resume incomplete jobs - server.py FastAPI: webhooks, /jobs, /briefs (optional deps) - tools.py allowlisted research tools - delivery.py hosted briefs + SMTP/outbox email - observability.py structured JSON logging - improver.py auto-tune pricing from job metrics - reconcile.py Stripe ↔ ledger reconciliation - gateway.py channel router (Telegram, dashboard) - chat.py Nemotron conversational loop + business tools - workspace.py SOUL/BRAIN/AGENTS prompt assembly - channels/ Telegram adapter - notifications.py worker→serve chat outbox - treasury.py ledger, jobs, stages, metrics (SQLite) - pricing.py margin gate + COGS overrides - guardrails.py NemoClaw-style spend policy - stripe_client.py Checkout Sessions + webhooks - nemotron.py Nemotron client + bounded tool loop - service.py research brief fulfillment - jobs.py sample inbound work - dashboard.py treasury HTML dashboard -run_demo.py CLI demo (--async, --serve) -docs/PRODUCTION.md production deployment guide -``` diff --git a/CASE_STUDY.md b/CASE_STUDY.md deleted file mode 100644 index 92cf742..0000000 --- a/CASE_STUDY.md +++ /dev/null @@ -1,129 +0,0 @@ -# SOLVENT: A Self-Funding AI Agent - -**A case study on building an agent that earns revenue, pays its own bills, and refuses unprofitable work.** - -*by Ian Alloway · [github.com/ianalloway/solvent-agent](https://github.com/ianalloway/solvent-agent)* - ---- - -## 1. The Thesis - -Here's the question that started it: **can an AI agent be self-funding?** - -Not "can it do tasks." Not "can it use tools." Those are solved problems. I mean: can an agent operate as a *business* — quote a job, collect payment, deliver the work, pay its own vendors, and end the day with more money than it started? Can its revenue exceed its compute costs, structurally and provably, by design rather than by luck? - -Most agents can spend money. Almost none can run as a business. The difference matters. An agent that buys API calls is a cost center. An agent with a treasury, a pricing engine, and a margin gate is something else — it's an autonomous economic unit. It has a balance sheet. It can be profitable or unprofitable. And if you get the architecture right, it can be *incapable* of losing money. - -I wanted to prove this wasn't theoretical. So I built SOLVENT: an agent that sells on-demand research briefs, collects payment through Stripe, fulfils the work using NVIDIA Nemotron, pays for its own inference and data costs, and tracks a real profit-and-loss statement in a SQLite ledger. Every job is profit-gated before it starts. Every payment is screened by a deterministic policy layer before it executes. The agent literally cannot spend more than it earns. - -This was built for the NVIDIA × Stripe × Nous Research Agent Accelerated Business Hackathon, but the idea outgrew the competition. I think it's one of the more honest demonstrations of what "agentic commerce" actually requires — and where it still breaks. - ---- - -## 2. Architecture - -SOLVENT is a stage machine. Each inbound job runs through the same pipeline, and every stage is idempotent — recorded in SQLite with a unique key, so a crash or restart resumes from exactly where it left off. No double-charges, no lost work. - -Here's the loop: - -``` -inbound job → MARGIN GATE → STRIPE (earn) → NEMOTRON (fulfil) - → GUARDRAILS → STRIPE (spend) → BOOK P&L -``` - -### Job intake - -Jobs arrive as structured requests — a research topic, a client budget, and estimated resource needs (token count, market-data calls, web searches). In the demo these come from a pre-loaded batch. In production they come from a web API, a Telegram bot with DM-based pairing, or the browser dashboard's chat panel. Every input passes through a security layer that sanitizes for prompt injection before it touches the model. - -### The margin gate (`pricing.py`) - -This is the core economic discipline. Before accepting any job, the agent estimates what the work will *cost* to fulfil — Nemotron inference at $0.30/1k tokens, market-data pulls at $1.20 each, web searches at $0.08, PDF render at $0.40, email delivery at $0.05. It compares the client's budget (which is the price — you can't charge more than someone will pay) against that estimated cost. If the projected margin doesn't clear a **35% floor**, or if the order is under a **$15 minimum**, the job is declined. No Stripe call is ever made. - -The agent is *structurally incapable of unprofitable work*. That's not a policy it follows — it's a gate it cannot pass. - -### Stripe — earn (`stripe_client.py`) - -Accepted jobs get a real Stripe Checkout Session. The agent creates a Payment Link, records the `cs_...` session ID and `pi_...` PaymentIntent on the ledger, and either polls the session until `payment_status == paid` (demo/sync mode) or waits for a `checkout.session.completed` webhook (production/async mode). Revenue is **always collected before cost is incurred**. The client refuses live Stripe keys outright — test mode only. Product and Price objects are cached locally so repeated runs reuse a single "SOLVENT Research Brief" product instead of cluttering your Stripe dashboard. - -### Fulfillment (`nemotron.py`, `service.py`) - -Payment confirmed, the work begins. The agent calls NVIDIA Nemotron (Llama-3.1-Nemotron-Ultra-253B) via the OpenAI-compatible endpoint at `integrate.api.nvidia.com`. The fulfillment loop is bounded: the model can request tools — `web_search`, `market_data`, `summarize` — but only from an allowlist, and only up to a capped number of rounds and total tool calls. After gathering evidence, it writes a decision-ready research brief in markdown, which gets rendered to HTML and hosted at a signed URL. - -The critical design choice here: **with no API key, the entire system still runs end-to-end on a deterministic offline stub.** The stub produces a plausible brief and returns estimated token counts. This means the demo always works — judges, recruiters, anyone can `git clone && python3 run_demo.py` and see the full money loop in 30 seconds with zero credentials. Add `NVIDIA_API_KEY` and the stub transparently swaps for live Nemotron inference. - -### Guardrails (`guardrails.py`) - -This is the answer to the question everyone asks: *"Would you actually give an agent a payment credential?"* - -Every outbound spend passes through five deterministic checks before any Stripe call: - -1. **Vendor allowlist** — money can only go to pre-approved vendors (Nemotron compute, market-data API, web-search API, PDF SaaS, email SaaS). -2. **Per-transaction cap** — no single payment over $50. -3. **Rolling 24-hour budget** — total spend bounded at $250/day. -4. **Solvency rule** — never spend below a $20 minimum cash reserve. -5. **ROI rule** — never spend on a job projected to be unprofitable. - -These aren't suggestions the model can override. They're plain Python that runs before the Stripe call, raises `GuardrailError` on violation, and are fully tested. If a spend is blocked after payment has already been collected, the agent automatically refunds the client via the original PaymentIntent and books the refund on the ledger. - -### The treasury (`treasury.py`) - -This is SOLVENT's economic memory. A SQLite database holds every ledger entry — capital seeds, revenue, expenses — each stamped with job ID, vendor, Stripe references, and timestamp. It tracks balance, revenue, expenses, net profit, and margin percentage. A separate `job_metrics` table records estimated vs. actual COGS for every job, enabling margin-drift detection and, eventually, automated pricing tuning. File-level locking with `fcntl` guarantees atomicity across processes. - ---- - -## 3. What Happened - -I ran a batch of four sample jobs to exercise the full loop. Here's what the agent did: - -| Job | Topic | Budget | Outcome | -|-----|-------|--------|---------| -| J1 | Competitive landscape for AI inference chips, 2026 | $49 | Accepted, fulfilled, booked | -| J2 | Stablecoin payment volumes and take-rate outlook | $75 | Accepted, fulfilled, booked | -| J3 | One-line definition of EBITDA | $6 | **Declined** — below $15 minimum | -| J4 | Edge-AI adoption in industrial robotics: 18-month outlook | $99 | Accepted, fulfilled, booked | - -**Final P&L: $223.00 revenue, $13.35 operating spend, $209.65 net profit, 94% margin.** - -The agent quoted J3 — a student wanting a cheap one-line answer for six bucks — and declined it without touching Stripe. That's the margin gate working as intended. The other three cleared the 35% floor, collected payment, produced briefs, paid their vendor bills (inference, data, rendering, delivery), and booked profit to the ledger. - -Now, let me be honest about what this is and isn't. Those revenue and cost figures are real in the sense that they flow through actual Stripe Checkout Sessions and a real ledger with real accounting. But in demo mode, Stripe runs in test mode (simulated payment confirmation), and without an `NVIDIA_API_KEY`, the Nemotron calls hit the offline stub. When you add both keys, the briefs are genuinely written by Llama-3.1-Nemotron-Ultra and the payment links are real Stripe test-mode links you can pay with `4242 4242 4242 4242`. - -What I proved is that the *architecture* works end-to-end: the margin gate correctly declines unprofitable work, the stage machine is crash-safe and idempotent, the guardrails block out-of-policy spends, the refund-on-failure path triggers correctly when a spend is blocked post-payment, and the treasury produces an honest P&L. The loop closes. The agent earns, it pays its bills, it books profit, and it declines work that doesn't pencil out. - -What I have *not* yet proven is whether this runs unattended against real paying customers at scale, over weeks, with live money. That's a different test, and it's next. - ---- - -## 4. Lessons - -**Structural profitability beats policy compliance.** I could have written "don't lose money" in the system prompt and hoped the model obeyed. Instead, the margin gate is deterministic code that runs before Stripe is ever called. The model has no say in whether an unprofitable job is accepted. This is the single most important design decision in the project. Agents will surprise you; arithmetic won't. - -**Offline-first is non-negotiable for demos.** The deterministic Nemotron stub saved me more times than I can count. Every demo, every judge interaction, every "let me just show you this" moment worked because the agent never depended on a network call being up. The stub isn't a hack — it's a first-class citizen that returns the same interface as the live path. - -**COGS reconciliation is where reality bites.** The `reconcile_cogs` function compares estimated cost against actual cost after fulfillment. Margin drift happens. The model uses more tokens than estimated; a web search returns more results than expected. The system flags drift above 15% and records it in `job_metrics`. This is the seed of the auto-improver — after enough jobs, `solvent tune` can propose pricing adjustments to keep margins honest. - -**Idempotency is the whole game.** Every stage writes a completion record with a unique key (`quote:J1`, `paid:J1:cs_...`, `spend:J1:nvidia-nemotron`). A crash at any point means a clean resume, not a double-charge or a half-delivered brief. This took more engineering than the "interesting" parts, and it's what separates a demo from something you could actually deploy. - -**The security layer matters more than you think.** Job inputs pass through prompt-injection sanitization before reaching the model. A malicious topic string can't trick the agent into requesting treasury actions or payment tools — those are explicitly outside the model's tool allowlist. When you give an agent a Stripe key, input validation stops being optional. - ---- - -## 5. What's Next - -The architecture is proven. The next phase is about making it real: - -- **Live operation.** Run SOLVENT against real test-mode Stripe with live Nemotron for an extended period — not a single batch, but continuous operation. Track whether margin drift accumulates, whether the auto-improver's pricing adjustments converge, whether the guardrails hold under load. - -- **More services.** Research briefs are the first product. The service layer is pluggable — any job type that can estimate its COGS upfront and produce a deliverable fits the loop. Code review, data analysis, document generation — same margin gate, same guardrails, same treasury. - -- **Stripe Issuing in production.** The spend side currently uses simulated vendor payments or test-mode Issuing virtual cards. Moving to capped, single-use virtual debit cards for each vendor payment would make outbound spend as real and auditable as inbound revenue. - -- **Multi-model fulfillment.** Nemotron is the reasoning engine today, but the `complete()` interface is model-agnostic. Different job types could route to different models at different price points — and the margin gate would decide which are economical. - -- **Telegram as the primary surface.** The bot already supports OpenClaw-style DM pairing, job commissioning in natural language, and push notifications when jobs are paid and delivered. Making chat the main interface — not the CLI — is where this starts to feel like a product instead of a repo. - -The bigger question this project raised for me: if an agent can be self-funding at the micro level — one job, one payment, one P&L entry — what happens at scale? What does a fleet of margin-gated agents look like? An agent economy isn't agents that can spend. It's agents that can *run a business*. SOLVENT is a proof that the loop closes. What comes next is finding out how far it scales. - ---- - -*Code: [github.com/ianalloway/solvent-agent](https://github.com/ianalloway/solvent-agent) · Ian Alloway · [ianalloway.xyz](https://ianalloway.xyz)* diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0ce4c0c..43acee1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ Thanks for your interest in SOLVENT! This is a hackathon project that I'm active git clone https://github.com/ianalloway/solvent-agent.git cd solvent-agent python3 run_demo.py # no install required for the demo -pip install pytest +pip install -e ".[dev]" python3 -m pytest tests/ -v # run the test suite ``` @@ -33,7 +33,7 @@ python3 -m pytest tests/ -v # run the test suite - Python 3.10+ with type hints where it helps readability. - No external dependencies in core `solvent/` (keep it importable with stdlib only). -- Dependencies in `requirements.txt` are for optional live integrations only. +- Third-party packages are opt-in extras declared in `pyproject.toml` (`stripe`, `serve`, `telegram`, `qr`, `dev`, `all`) — never a hard dependency of core. ## Questions? diff --git a/ONBOARDING.md b/ONBOARDING.md deleted file mode 100644 index aa447cb..0000000 --- a/ONBOARDING.md +++ /dev/null @@ -1,41 +0,0 @@ -# SOLVENT Onboarding — Design Notes - -Patterns borrowed from **NVIDIA NemoClaw/OpenClaw** and **Nous Hermes** for the -first-run terminal wizard. - -## NemoClaw / OpenClaw patterns - -| Pattern | Source | SOLVENT adoption | -|---------|--------|------------------| -| Dedicated `onboard` command as lifecycle entry point | `nemoclaw onboard` | `python3 run_demo.py` runs wizard when no `.solvent/config.json` exists; `--onboard` to reconfigure | -| Provider-first wizard (pick inference, then credential) | NemoClaw quickstart | Step 1: model provider (offline stub vs Nemotron); warn if `NVIDIA_API_KEY` missing | -| Numbered provider menu | NemoClaw inference options | `[1] Offline stub`, `[2] NVIDIA Nemotron`, `[3] Custom (documented)` | -| Non-interactive escape hatch | `nemoclaw onboard --non-interactive`, env overrides | `--no-onboard`, `SOLVENT_SKIP_ONBOARD=1` → defaults without prompts | -| Credentials in local store (not repo) | `~/.nemoclaw/credentials.json` | `.solvent/config.json` (gitignored); API keys stay in env | -| Runtime model switch without full reinstall | `openshell inference set` | Config `model` field; re-run `--onboard` to change | -| Status banner after setup | `nemoclaw status` | Post-wizard summary: model, mode, Stripe sim/live | -| DM pairing for chat channels | OpenClaw `dmPolicy: pairing` | Telegram `/start` → `python -m solvent pairing approve` | -| Agent workspace files | OpenClaw `~/.openclaw/workspace` | `.solvent/workspace/` seeded on onboard · `python -m solvent workspace setup` | - -## Hermes / Nous patterns - -| Pattern | Source | SOLVENT adoption | -|---------|--------|------------------| -| `hermes setup` interactive wizard on first boot | Hermes quickstart | Auto-run wizard when config missing | -| Quick vs full setup tiers | Hostinger Hermes tutorial | Single quick wizard (3 steps); programmatic mode = “skip to library” | -| Provider + model selection | `hermes model` | Model step with offline default for zero-credential demos | -| Interaction surface choice | `hermes` CLI vs `hermes --tui` vs `hermes gateway` | Batch demo / interactive REPL / programmatic-only guidance | -| Config in `~/.hermes/` | Hermes docs | `.solvent/config.json` | -| `hermes doctor` for diagnostics | Hermes CLI | `python -m solvent doctor` | -| Channel gateway + pairing | OpenClaw `dmPolicy: pairing` | `solvent/gateway.py`, `solvent/channels/telegram.py`, `python -m solvent pairing` | -| Progressive tool disclosure | Hermes `tool_search` / `tool_call` | `solvent/hermes_tools.py` when catalog > 10 tools | -| Session memory | Hermes chat history | `solvent/memory.py` · `chat_messages` table | -| Agent workspace (brain) | OpenClaw workspace + Hermes SOUL | `.solvent/workspace/` · `SOUL.md`, `BRAIN.md`, `AGENTS.md` · [docs/WORKSPACE.md](docs/WORKSPACE.md) | -| Welcome banner with active model | Hermes TUI | Colored banner in `onboarding.py` matching `run_demo.py` | - -## SOLVENT-specific choices - -- **Terminal-first** — no web UI; hackathon judges run `python3 run_demo.py` in one terminal. -- **Offline default** — matches “zero install, zero keys” README promise. -- **Stripe optional** — toggle test mode; still requires `STRIPE_API_KEY` in env for live test links. -- **CLI overrides config** — `-i` / `--interactive` wins over saved `interaction_mode` for power users. diff --git a/README.md b/README.md index 911a637..10dad21 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,8 @@ Third-party features are **opt-in extras** — install only what you need: pip install -e ".[stripe]" # real Stripe test-mode payment links pip install -e ".[serve]" # FastAPI webhooks + hosted briefs pip install -e ".[telegram]" # Telegram bot channel -pip install -e ".[rich]" # live terminal dashboard (solvent tui) +pip install -e ".[qr]" # scannable QR codes for OpenClaw pairing +pip install -e ".[dev]" # pytest, for running the test suite pip install -e ".[all]" # everything ``` @@ -190,39 +191,26 @@ snap = agent.run(SAMPLE_JOBS[1:]) # process a list; returns snapshot print(snap["balance_cents"], snap["margin_pct"]) ``` -### Guardrails demo (standalone) - -```bash -python3 demo_guardrails.py -``` - -Shows five spend attempts and which ones the NemoClaw-style policy blocks — without starting the agent or touching Stripe. - ### Production mode (webhooks + async worker) ```bash -pip install -r requirements.txt -r requirements-serve.txt +pip install -e ".[serve]" python3 -m solvent serve --port 8787 # webhooks + job API + hosted briefs python3 -m solvent worker # resume incomplete jobs, process queue # Interactive voice dashboard (chat + live SSE updates): open http://127.0.0.1:8787/ - -# Or dev convenience: -python3 run_demo.py --serve --no-onboard ``` The hosted dashboard at `/` includes a **chat panel** (type or use the mic with Web Speech API) and **live treasury updates** via Server-Sent Events (`/api/events`). Chat hits `/api/chat`, which routes through the Nemotron agent loop. -See [docs/PRODUCTION.md](docs/PRODUCTION.md) for Stripe webhook setup, SMTP delivery, reconciliation, and auto-tuning. +See [docs/PRODUCTION.md](docs/PRODUCTION.md) for Stripe webhook setup, SMTP delivery, and reconciliation. ### Operations ```bash python3 -m solvent reconcile --since 7d # Stripe ↔ ledger drift check -python3 -m solvent tune # propose pricing improvements (dry-run) -python3 -m solvent tune --apply # apply after 5+ completed jobs python3 -m solvent finance # income statement, unit economics, runway python3 -m solvent finance --json # machine-readable report python3 -m solvent finance --reserve 50 # runway to a $50 cash-reserve floor @@ -245,7 +233,7 @@ forecast also render as a **Financial Statement** panel in the HTML dashboard. To use live Nemotron inference and real Stripe test-mode payment links: ```bash -pip install -r requirements.txt +pip install -e ".[stripe]" export NVIDIA_API_KEY=nvapi-... # from build.nvidia.com export STRIPE_API_KEY=sk_test_... # Stripe test mode only (live keys refused) @@ -285,7 +273,7 @@ Product/Price objects are cached in `.solvent/stripe_catalog.json` so repeated r Full chat on Telegram with OpenClaw-style pairing and Hermes-style tool/memory patterns. See **[docs/TELEGRAM.md](docs/TELEGRAM.md)**. ```bash -pip install -r requirements-telegram.txt +pip install -e ".[telegram]" export TELEGRAM_BOT_TOKEN=... python -m solvent serve & # Stripe webhooks + checkout @@ -305,7 +293,7 @@ Personality and operating rules come from the **agent workspace** (`SOUL.md`, `B ## 🧪 Tests ```bash -pip install pytest +pip install -e ".[dev]" python3 -m pytest tests/ -v ``` @@ -318,6 +306,7 @@ Unit tests cover: pricing & margin gate · guardrail policy · treasury ledger ``` solvent/ agent.py the orchestrator (earn → fulfil → spend → book) + stages.py idempotent stage machine (quote→paid→fulfill→deliver→spend) treasury.py SQLite ledger / balance sheet pricing.py the margin gate guardrails.py NemoClaw-style spend policy @@ -328,17 +317,17 @@ solvent/ dashboard.py renders the treasury to HTML + JSON finance.py income statement · unit economics · runway · forecast config.py onboarding wizard and config persistence + server.py FastAPI webhooks + job API + hosted briefs (serve) + worker.py async job processor + resume incomplete jobs gateway.py channel router (Telegram → chat sessions) chat.py conversational loop + business tools memory.py Hermes-style session memory - hermes_tools.py progressive tool disclosure bridge doctor.py stack diagnostics workspace.py SOUL/BRAIN/AGENTS prompt assembly channels/ Telegram long-poll adapter run_demo.py the full business loop (CLI entry point) -demo_guardrails.py the safety story (standalone) tests/ pytest suite -docs/ screenshots and supporting assets +docs/ screenshots and supporting docs ``` --- diff --git a/demo_guardrails.py b/demo_guardrails.py deleted file mode 100644 index 2b1baf0..0000000 --- a/demo_guardrails.py +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env python3 -""" -demo_guardrails.py — prove the agent cannot spend unsafely. - -Screens five payments through the NemoClaw-style policy layer, demonstrating -the guardrail rejecting four distinct policy violations before hitting Stripe. -""" - -from solvent.treasury import Treasury -from solvent.guardrails import Guardrails, GuardrailError - -# ANSI colors -C_RESET = "\033[0m" -C_BOLD = "\033[1m" -C_RED = "\033[31m" -C_GREEN = "\033[32m" -C_YELLOW = "\033[33m" -C_BLUE = "\033[34m" -C_GREY = "\033[90m" - -BAR = f"{C_GREY}{'─' * 66}{C_RESET}" - -t = Treasury() -t.reset() -t.seed(3_000) # $30 operating cash on hand -g = Guardrails(t) - -attempts = [ - ("Pay a vendor NOT on the allowlist", 900, "sketchy-vendor", None), - ("Single payment above the $50 cap", 6_000, "market-data-api", None), - ("Spend that breaks the $20 cash reserve", 2_000, "nvidia-nemotron", None), - ("Spend on a job projected to lose money", 500, "nvidia-nemotron", -200), - ("A normal, in-policy payment", 240, "market-data-api", 4_000), -] - -print(f"\n🛡️ {C_BOLD}SOLVENT Spend Guardrails (NemoClaw-Style Spend Screening){C_RESET}") -print(BAR) - -for label, amt, vendor, margin in attempts: - try: - g.check_spend(amt, vendor, projected_job_margin_cents=margin) - print(f" {C_GREEN}✅ ALLOWED{C_RESET} ${amt/100:>6.2f} → {vendor:<16} | {C_BOLD}{label}{C_RESET}") - except GuardrailError as e: - print(f" {C_RED}🛑 BLOCKED{C_RESET} ${amt/100:>6.2f} → {vendor:<16} | {label}\n {C_GREY}reason:{C_RESET} {C_RED}{e}{C_RESET}") - -print(BAR) -print(" Every outbound payment is screened by guardrails before reaching Stripe.\n") diff --git a/docs/PRODUCTION.md b/docs/PRODUCTION.md index 1e3b7e5..e3dda6e 100644 --- a/docs/PRODUCTION.md +++ b/docs/PRODUCTION.md @@ -11,8 +11,7 @@ python3 run_demo.py --no-onboard ## HTTP server + worker (production shape) ```bash -pip install -r requirements.txt -pip install -r requirements-serve.txt +pip install -e ".[serve]" export SOLVENT_BASE_URL=http://127.0.0.1:8787 export SOLVENT_DELIVERY_SECRET=$(python3 -c 'import secrets; print(secrets.token_urlsafe(48))') @@ -23,9 +22,6 @@ open http://127.0.0.1:8787/ # Terminal 2 — async job processor python3 -m solvent worker - -# Or combined dev mode: -python3 run_demo.py --serve --no-onboard ``` ### Interactive dashboard + voice @@ -79,10 +75,6 @@ export SOLVENT_LOG_JSON=1 # Reconcile Stripe vs ledger python3 -m solvent reconcile --since 7d - -# Auto-improvement (dry-run by default) -python3 -m solvent tune -python3 -m solvent tune --apply ``` ## Environment variables @@ -107,4 +99,4 @@ POST /jobs → quote → Checkout Session → awaiting_payment worker → paid → fulfill (tool agent) → deliver → spend → book ``` -Idempotent stages are recorded in SQLite (`job_stages`). Metrics in `job_metrics` feed `solvent tune`. +Idempotent stages are recorded in SQLite (`job_stages`). Estimated vs. actual COGS are recorded in `job_metrics` for margin-drift analysis. diff --git a/docs/TELEGRAM.md b/docs/TELEGRAM.md index ac263b1..460b65d 100644 --- a/docs/TELEGRAM.md +++ b/docs/TELEGRAM.md @@ -8,7 +8,7 @@ Talk to your self-funding research agent on Telegram. Pairing, conversational co 2. Install Telegram dependencies: ```bash -pip install -r requirements-telegram.txt +pip install -e ".[telegram]" ``` 3. Export the token (never commit it): diff --git a/docs/WORKSPACE.md b/docs/WORKSPACE.md index 839b8fb..8dc6387 100644 --- a/docs/WORKSPACE.md +++ b/docs/WORKSPACE.md @@ -7,20 +7,15 @@ SOLVENT adopts the **OpenClaw workspace** and **Hermes SOUL** patterns: markdown ``` .solvent/workspace/ ├── SOUL.md # Identity, tone, values (Hermes slot #1) -├── IDENTITY.md # Name, emoji, intro line ├── BRAIN.md # Active pipeline / NOW state (read every session) ├── AGENTS.md # Operating rules and workflows -├── USER.md # Who you are -├── TOOLS.md # Tool and CLI conventions -├── HEARTBEAT.md # Optional proactive checklist -├── MEMORY.md # Curated long-term memory (main DM only) -├── BOOTSTRAP.md # One-time first-run ritual +├── MEMORY.md # Optional curated long-term memory (main sessions only; not seeded by default) ├── memory/ -│ └── YYYY-MM-DD.md +│ └── YYYY-MM-DD.md # Daily event log, appended automatically └── skills/ └── {name}/SKILL.md # OpenClaw/Hermes skill layout -.solvent/skills/ # Learned skills (improver promotions) +.solvent/skills/ # Learned skills (promoted via solvent.workspace.promote_skill) ``` Repo-root **`AGENTS.md`** (this repository) is loaded as Hermes-style project context alongside the workspace files. @@ -30,19 +25,18 @@ Repo-root **`AGENTS.md`** (this repository) is loaded as Hermes-style project co | Order | File | Loaded when | |-------|------|-------------| | 1 | `SOUL.md` | Every session (identity) | -| 2 | `IDENTITY.md` | Every session | -| 3 | Core economic rules | Every session (code) | -| 4 | `BRAIN.md`, `AGENTS.md`, `TOOLS.md`, `USER.md` | Project context | -| 5 | `MEMORY.md` | Main/private sessions only (Telegram DM, CLI) | -| 6 | `memory/today+yesterday` | When present | -| 7 | `skills/*.md` | When present | +| 2 | Core economic rules | Every session (code) | +| 3 | `BRAIN.md`, `AGENTS.md` | Project context | +| 4 | `MEMORY.md` | Main/private sessions only, if present | +| 5 | `memory/today+yesterday` | When present | +| 6 | `skills/*.md` | When present | -Shared/group channels skip `MEMORY.md` (OpenClaw privacy pattern). +Shared/group channels skip `MEMORY.md` (OpenClaw privacy pattern) — create the file yourself under `.solvent/workspace/MEMORY.md` if you want durable curated facts; it is not seeded by default. ## Setup ```bash -python -m solvent workspace setup # seed templates (never overwrites) +python -m solvent workspace setup # seed SOUL/AGENTS/BRAIN templates (never overwrites) python -m solvent workspace list # show files + sizes python -m solvent doctor # checks SOUL/AGENTS/BRAIN exist ``` @@ -58,16 +52,14 @@ Edit files in `.solvent/workspace/` — changes apply on the next message. | If it describes… | Put it in… | |------------------|------------| | Who the agent is, tone, values | `SOUL.md` | -| Who you are | `USER.md` | | How to work, workflows | `AGENTS.md` | | What's happening *now* | `BRAIN.md` | -| Tool/CLI notes | `TOOLS.md` | -| Durable learned facts | `MEMORY.md` | +| Durable learned facts (optional) | `MEMORY.md` | | Day-to-day log | `memory/YYYY-MM-DD.md` | ## BRAIN.md -Not in upstream OpenClaw, but widely used: a living dashboard SOLVENT reads each session (referenced from `SOUL.md`). Update pipeline, blockers, and next actions — or ask the bot to update it after job events. +Not in upstream OpenClaw, but widely used: a living dashboard SOLVENT reads each session (referenced from `SOUL.md`). Update pipeline, blockers, and next actions. Job lifecycle events (`paid`, `fulfilled`, `delivered`) append to today's daily memory log automatically. @@ -80,4 +72,3 @@ export SOLVENT_WORKSPACE=/path/to/custom/workspace ## Related - [TELEGRAM.md](TELEGRAM.md) — chat channel uses this workspace for every turn -- [ONBOARDING.md](../ONBOARDING.md) — wizard patterns from NemoClaw/Hermes diff --git a/docs/logo.png b/docs/logo.png deleted file mode 100644 index f4805ca..0000000 Binary files a/docs/logo.png and /dev/null differ diff --git a/pyproject.toml b/pyproject.toml index 26ad3d2..e22105c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,16 +22,15 @@ dependencies = [] [project.optional-dependencies] stripe = ["stripe>=9.0.0"] -rich = ["rich>=13.0.0"] serve = ["fastapi>=0.110.0", "uvicorn[standard]>=0.27.0"] qr = ["qrcode[pil]>=7.0"] telegram = ["python-telegram-bot>=21.0"] dev = ["pytest>=7.0.0"] all = [ "stripe>=9.0.0", - "rich>=13.0.0", "fastapi>=0.110.0", "uvicorn[standard]>=0.27.0", + "qrcode[pil]>=7.0", "python-telegram-bot>=21.0", ] diff --git a/solvent/__main__.py b/solvent/__main__.py index 351b713..c1c8a03 100644 --- a/solvent/__main__.py +++ b/solvent/__main__.py @@ -20,7 +20,6 @@ worker resume incomplete jobs, process the queue telegram long-poll the Telegram bot finance income statement, unit economics, runway, forecast (alias: report) - tune propose pricing improvements (--apply to commit) reconcile Stripe <-> ledger drift check doctor stack diagnostics pairing manage Telegram DM pairing codes @@ -78,10 +77,6 @@ def main(): sys.argv.pop(1) from .worker import main as worker_main worker_main() - elif len(sys.argv) > 1 and sys.argv[1] == "tune": - sys.argv.pop(1) - from .improver import main as tune_main - tune_main() elif len(sys.argv) > 1 and sys.argv[1] == "reconcile": sys.argv.pop(1) from .reconcile import main as reconcile_main @@ -102,20 +97,17 @@ def main(): sys.argv.pop(1) from .workspace import main as workspace_main workspace_main() - elif len(sys.argv) > 1 and sys.argv[1] == "tui": - from .tui import run - run() elif len(sys.argv) > 1 and sys.argv[1] == "retry": job_id = sys.argv[2] if len(sys.argv) > 2 else None if not job_id: print("Usage: python -m solvent retry ") sys.exit(1) - from .stages import SolventStages + from .stages import StageRunner from .treasury import Treasury from .guardrails import Guardrails from .stripe_client import StripeClient t = Treasury() - s = SolventStages(treasury=t, guard=Guardrails(t), stripe=StripeClient()) + s = StageRunner(treasury=t, guard=Guardrails(t), stripe=StripeClient()) result = s.retry_job(job_id) import json print(json.dumps(result, indent=2, default=str)) @@ -138,8 +130,12 @@ def main(): print(f" {row['event_id'][:16]} {row['event_type']} err={row['error'][:60]}") else: # No subcommand: fall through to the demo / interactive CLI. - from .upgrade import background_update_hint - background_update_hint() + # Update checks are opt-in only (SOLVENT_UPDATE_CHECK=1) — run + # `solvent upgrade` explicitly to check for a newer version. + import os + if os.environ.get("SOLVENT_UPDATE_CHECK", "").strip() in ("1", "true", "yes"): + from .upgrade import background_update_hint + background_update_hint() from .cli import main as demo_main demo_main() diff --git a/solvent/channels/telegram.py b/solvent/channels/telegram.py index 602e059..40a212a 100644 --- a/solvent/channels/telegram.py +++ b/solvent/channels/telegram.py @@ -30,7 +30,7 @@ def _require_ptb(): return Update, Application, CommandHandler, MessageHandler, filters, ContextTypes except ImportError as exc: raise RuntimeError( - "python-telegram-bot required. Install: pip install -r requirements-telegram.txt" + "python-telegram-bot required. Install: pip install -e \".[telegram]\"" ) from exc diff --git a/solvent/chat.py b/solvent/chat.py index fb1651a..150ddbb 100644 --- a/solvent/chat.py +++ b/solvent/chat.py @@ -8,7 +8,6 @@ import uuid from .agent import Solvent -from .hermes_tools import ToolRegistry from .memory import SessionMemory from . import nemotron from . import tools @@ -182,13 +181,14 @@ def handle_message( live_search = os.environ.get("SOLVENT_LIVE_SEARCH", "").strip() in ("1", "true", "yes") catalog = {**tools.TOOL_REGISTRY, **tools.BUSINESS_TOOL_REGISTRY} - registry = ToolRegistry(catalog, _make_executor(agent, session_id, live_search)) + executor = _make_executor(agent, session_id, live_search) + tool_lines = "\n".join(f"- {name}: {meta.get('description', '')}" for name, meta in catalog.items()) slot_hint = _pending_prompt(pending) user = ( f"Conversation so far:\n{history}\n\n" - f"Available tools: {', '.join(registry.visible_tools())}\n" - f"{registry.describe_catalog()}\n" + f"Available tools: {', '.join(sorted(catalog))}\n" + f"{tool_lines}\n" ) if slot_hint: user += f"\n{slot_hint}\n" @@ -210,7 +210,7 @@ def handle_message( "answer the user now without more tool calls." ) break - result = registry.dispatch(name, args) + result = executor(name, args) calls_made += 1 notes.append(f"[{name}] {result}") user = user + f"\n\nAssistant: {reply}\n\nTool results:\n" + "\n".join(notes) diff --git a/solvent/cli.py b/solvent/cli.py index 6e30261..6801609 100644 --- a/solvent/cli.py +++ b/solvent/cli.py @@ -7,11 +7,13 @@ run Nemotron reasoning, screen payments through guardrails, and book the P&L. Outputs a premium, interactive treasury_dashboard.html at the end. -First run: interactive onboarding wizard (see ONBOARDING.md). +First run: interactive onboarding wizard. Skip wizard: --no-onboard or SOLVENT_SKIP_ONBOARD=1 Reconfigure: --onboard """ +import os +import secrets import sys import time import argparse @@ -255,13 +257,20 @@ def resolve_config(args) -> SolventConfig: return cfg -def main(): - # Fast-path: tui subcommand bypasses argparse entirely - if len(sys.argv) > 1 and sys.argv[1] == "tui": - from .tui import run - run() - return +def _ensure_delivery_secret() -> None: + """Generate a per-run SOLVENT_DELIVERY_SECRET for the zero-config demo. + + Hosted brief links are HMAC-signed and `delivery.py` deliberately refuses + to run without a real secret (see docs/PRODUCTION.md). The CLI demo has + no persistent server to protect, so it's safe to mint a random one here + when the operator hasn't set one — production (`solvent serve`/`worker`) + still requires an explicit, persisted `SOLVENT_DELIVERY_SECRET`. + """ + if len(os.environ.get("SOLVENT_DELIVERY_SECRET", "").strip()) < 32: + os.environ["SOLVENT_DELIVERY_SECRET"] = secrets.token_urlsafe(48) + +def main(): parser = argparse.ArgumentParser( description="Run SOLVENT — a self-funding analyst agent.", ) @@ -293,6 +302,7 @@ def main(): ) args = parser.parse_args() + _ensure_delivery_secret() cfg = resolve_config(args) apply_config(cfg) diff --git a/solvent/doctor.py b/solvent/doctor.py index 5ec62a2..2315b3c 100644 --- a/solvent/doctor.py +++ b/solvent/doctor.py @@ -12,7 +12,6 @@ # Optional extras: (human label, import name, what it unlocks). _EXTRAS = [ - ("rich", "rich", "terminal dashboard"), ("fastapi", "fastapi", "webhooks + hosted briefs"), ("uvicorn", "uvicorn", "server runtime"), ("stripe", "stripe", "live Stripe test-mode"), @@ -99,7 +98,7 @@ def add(name: str, ok: bool, detail: str = ""): solvent --help list all commands Enable live features: - pip install -e ".[all]" rich TUI · Stripe · server · Telegram + pip install -e ".[all]" Stripe · server · Telegram · QR pairing export NVIDIA_API_KEY=nvapi-... live Nemotron inference export STRIPE_API_KEY=sk_test_... real test-mode payment links """ diff --git a/solvent/hermes_tools.py b/solvent/hermes_tools.py deleted file mode 100644 index 6d645a7..0000000 --- a/solvent/hermes_tools.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Hermes-style progressive tool disclosure bridge.""" - -from __future__ import annotations - -import json -from typing import Callable - -BRIDGE_TOOLS = frozenset({"tool_search", "tool_describe", "tool_call"}) -DISCLOSURE_THRESHOLD = 10 - - -class ToolRegistry: - """Session-scoped tool catalog with optional bridge mode.""" - - def __init__( - self, - tools: dict[str, dict], - executor: Callable[[str, dict], str], - *, - force_bridge: bool = False, - ): - self._all = tools - self._executor = executor - self._allowed = set(tools.keys()) - self._use_bridge = force_bridge or len(tools) > DISCLOSURE_THRESHOLD - - def visible_tools(self) -> list[str]: - if self._use_bridge: - return sorted(BRIDGE_TOOLS) - return sorted(self._allowed) - - def describe_catalog(self) -> str: - if not self._use_bridge: - lines = [] - for name, meta in self._all.items(): - lines.append(f"- {name}: {meta.get('description', '')}") - return "\n".join(lines) - return ( - "Tools are accessed via tool_search, tool_describe, tool_call. " - f"Catalog size: {len(self._all)} tools." - ) - - def tool_search(self, query: str) -> str: - q = (query or "").lower() - matches = [] - for name, meta in self._all.items(): - hay = f"{name} {meta.get('description', '')} {meta.get('category', '')}".lower() - if not q or q in hay: - matches.append({"name": name, "description": meta.get("description", "")}) - return json.dumps(matches[:15], indent=2) - - def tool_describe(self, name: str) -> str: - if name in BRIDGE_TOOLS: - schemas = { - "tool_search": {"query": "string"}, - "tool_describe": {"name": "string"}, - "tool_call": {"name": "string", "arguments": "object"}, - } - return json.dumps({"name": name, "parameters": schemas[name]}) - meta = self._all.get(name) - if not meta: - return json.dumps({"error": f"unknown tool {name!r}"}) - return json.dumps({ - "name": name, - "description": meta.get("description", ""), - "parameters": meta.get("parameters", {}), - "category": meta.get("category", ""), - }) - - def tool_call(self, name: str, arguments: dict | None = None) -> str: - arguments = arguments or {} - if name in BRIDGE_TOOLS: - if name == "tool_search": - return self.tool_search(arguments.get("query", "")) - if name == "tool_describe": - return self.tool_describe(arguments.get("name", "")) - if name == "tool_call": - return self.tool_call( - arguments.get("name", ""), - arguments.get("arguments") or {}, - ) - if name not in self._allowed: - return json.dumps({"error": f"tool {name!r} not in session allowlist"}) - try: - return self._executor(name, arguments) - except Exception as exc: - return json.dumps({"error": str(exc)}) - - def dispatch(self, name: str, arguments: dict) -> str: - if self._use_bridge and name not in BRIDGE_TOOLS: - return self.tool_call(name, arguments) - if name in BRIDGE_TOOLS: - return self.tool_call(name, arguments) - return self.tool_call(name, arguments) diff --git a/solvent/improver.py b/solvent/improver.py deleted file mode 100644 index 0b88e46..0000000 --- a/solvent/improver.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Auto-improvement loop: tune pricing and guardrails from job metrics.""" - -from __future__ import annotations - -import json -import time -from pathlib import Path - -from .pricing import RESOURCE_COSTS_CENTS -from .treasury import Treasury -from .workspace import promote_skill - -IMPROVEMENT_LOG = Path("data/improvement_log.jsonl") -OVERRIDES_PATH = Path(".solvent/pricing_overrides.json") -PROMPTS_DIR = Path(".solvent/prompts") - - -def analyze_metrics(treasury: Treasury) -> dict: - metrics = treasury.list_metrics() - completed = [m for m in metrics if m.get("refunded", 0) == 0 and m.get("actual_cost_cents")] - if len(completed) < 5: - return {"ready": False, "reason": f"need 5+ completed jobs, have {len(completed)}"} - margin_errors = [ - (m.get("actual_margin_pct") or 0) - (m.get("est_margin_pct") or 0) - for m in completed - ] - avg_margin_error = sum(margin_errors) / len(margin_errors) - refund_rate = sum(1 for m in metrics if m.get("refunded")) / max(len(metrics), 1) - fulfill_times = [m.get("fulfillment_seconds") or 0 for m in completed] - avg_fulfill = sum(fulfill_times) / len(fulfill_times) if fulfill_times else 0 - return { - "ready": True, - "avg_margin_error": avg_margin_error, - "refund_rate": refund_rate, - "avg_fulfillment_seconds": avg_fulfill, - "sample_size": len(completed), - } - - -def propose_changes(analysis: dict) -> list[dict]: - proposals: list[dict] = [] - if not analysis.get("ready"): - return proposals - if analysis["avg_margin_error"] < -5: - current = RESOURCE_COSTS_CENTS["nemotron_tokens_per_1k"] - proposals.append({ - "type": "pricing", - "key": "nemotron_tokens_per_1k", - "from": current, - "to": int(current * 1.05), - "reason": f"avg margin error {analysis['avg_margin_error']:.1f}%", - }) - if analysis["refund_rate"] > 0.10: - proposals.append({ - "type": "guardrail", - "key": "daily_budget_cents", - "action": "reduce_10pct", - "reason": f"refund rate {analysis['refund_rate']:.0%}", - }) - if analysis["avg_fulfillment_seconds"] > 120: - proposals.append({ - "type": "agent", - "key": "max_tool_rounds", - "action": "reduce_by_1", - "reason": f"avg fulfillment {analysis['avg_fulfillment_seconds']:.0f}s", - }) - return proposals - - -def apply_changes(proposals: list[dict]) -> list[dict]: - applied: list[dict] = [] - OVERRIDES_PATH.parent.mkdir(parents=True, exist_ok=True) - overrides = {} - if OVERRIDES_PATH.is_file(): - try: - overrides = json.loads(OVERRIDES_PATH.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - overrides = {} - for p in proposals: - if p["type"] == "pricing": - overrides[p["key"]] = p["to"] - applied.append(p) - elif p["type"] == "agent": - promote_skill( - p.get("key", "agent-tuning"), - f"# Auto-tune proposal\n\nReason: {p.get('reason', '')}\nAction: {p.get('action', '')}\n", - ) - applied.append(p) - if overrides: - OVERRIDES_PATH.write_text(json.dumps(overrides, indent=2) + "\n", encoding="utf-8") - return applied - - -def log_improvement(entry: dict) -> None: - IMPROVEMENT_LOG.parent.mkdir(parents=True, exist_ok=True) - entry["ts"] = time.time() - with IMPROVEMENT_LOG.open("a", encoding="utf-8") as f: - f.write(json.dumps(entry) + "\n") - - -def tune(treasury: Treasury | None = None, *, apply: bool = False) -> dict: - treasury = treasury or Treasury() - analysis = analyze_metrics(treasury) - proposals = propose_changes(analysis) - result = {"analysis": analysis, "proposals": proposals, "applied": []} - if apply and proposals: - result["applied"] = apply_changes(proposals) - log_improvement({"action": "apply", "proposals": proposals}) - elif proposals: - log_improvement({"action": "dry_run", "proposals": proposals}) - return result - - -def main(): - import argparse - parser = argparse.ArgumentParser(description="SOLVENT auto-improvement tuner") - parser.add_argument("--apply", action="store_true", help="apply proposed changes") - args = parser.parse_args() - result = tune(apply=args.apply) - print(json.dumps(result, indent=2)) - - -if __name__ == "__main__": - main() diff --git a/solvent/pricing.py b/solvent/pricing.py index 50b0b89..fdc0a55 100644 --- a/solvent/pricing.py +++ b/solvent/pricing.py @@ -61,7 +61,7 @@ class PricingPolicy: def get_resource_costs() -> Dict[str, int]: - """Return effective resource costs, applying improver overrides if present.""" + """Return effective resource costs, applying overrides from .solvent/pricing_overrides.json if present.""" from pathlib import Path import json costs = dict(RESOURCE_COSTS_CENTS) diff --git a/solvent/server.py b/solvent/server.py index db823e4..ae7c9a5 100644 --- a/solvent/server.py +++ b/solvent/server.py @@ -53,7 +53,7 @@ def _require_fastapi(): except ImportError as exc: raise RuntimeError( "FastAPI is required for `solvent serve`. " - "Install with: pip install -r requirements-serve.txt" + "Install with: pip install -e \".[serve]\"" ) from exc @@ -383,7 +383,7 @@ def main(): try: import uvicorn except ImportError as exc: - raise RuntimeError("uvicorn required: pip install -r requirements-serve.txt") from exc + raise RuntimeError("uvicorn required: pip install -e \".[serve]\"") from exc app = create_app(seed_cents=int(args.seed * 100), fresh=not args.keep_balance) uvicorn.run(app, host=args.host, port=args.port) diff --git a/solvent/stages.py b/solvent/stages.py index 9900651..87a38b3 100644 --- a/solvent/stages.py +++ b/solvent/stages.py @@ -591,7 +591,3 @@ def retry_job(self, job_id: str) -> dict: else: # Failed before payment — restart from quote return self.run_job(job) - - -# Alias so ``python -m solvent retry`` can import SolventStages from this module. -SolventStages = StageRunner diff --git a/solvent/templates/workspace/BOOTSTRAP.md b/solvent/templates/workspace/BOOTSTRAP.md deleted file mode 100644 index eae43b3..0000000 --- a/solvent/templates/workspace/BOOTSTRAP.md +++ /dev/null @@ -1,16 +0,0 @@ -# BOOTSTRAP — first-run ritual - -Complete once, then delete this file or add `[complete]` anywhere below. - -## Ritual - -1. Edit **USER.md** with your name and email. -2. Skim **SOUL.md** and **AGENTS.md** — adjust tone/rules if needed. -3. Set **BRAIN.md** pipeline to your current priorities. -4. Run `python -m solvent doctor` and fix any FAIL lines. -5. If using Telegram: `export TELEGRAM_BOT_TOKEN=...` then `python -m solvent telegram`. -6. Send `/start` to the bot and `python -m solvent pairing approve `. - -When done, delete BOOTSTRAP.md or mark: - -`[complete]` diff --git a/solvent/templates/workspace/HEARTBEAT.md b/solvent/templates/workspace/HEARTBEAT.md deleted file mode 100644 index e4113dc..0000000 --- a/solvent/templates/workspace/HEARTBEAT.md +++ /dev/null @@ -1,8 +0,0 @@ -# HEARTBEAT - -_Keep short — optional checklist for proactive / worker heartbeat runs._ - -- [ ] Any jobs stuck in `awaiting_payment` > 24h? -- [ ] Treasury balance above minimum reserve? -- [ ] BRAIN.md pipeline section current? -- [ ] Pending Telegram pairings to approve? diff --git a/solvent/templates/workspace/IDENTITY.md b/solvent/templates/workspace/IDENTITY.md deleted file mode 100644 index 8e6cbbd..0000000 --- a/solvent/templates/workspace/IDENTITY.md +++ /dev/null @@ -1,10 +0,0 @@ -# Identity - -- **Name:** SOLVENT -- **Emoji:** 🪙 -- **Role:** Self-funding research analyst business -- **Vibe:** Calm operator — treasury-minded, margin-aware, delivery-focused - -## How I introduce myself - -> I'm SOLVENT — your self-funding research desk. I quote briefs against real costs, send Stripe checkout when you're ready, and deliver hosted reports when paid. Ask about balance, jobs, or commission a new brief. diff --git a/solvent/templates/workspace/MEMORY.md b/solvent/templates/workspace/MEMORY.md deleted file mode 100644 index 8642043..0000000 --- a/solvent/templates/workspace/MEMORY.md +++ /dev/null @@ -1,15 +0,0 @@ -# MEMORY — curated long-term - -_Durable facts only. Detailed logs belong in `memory/YYYY-MM-DD.md`._ - -## Business facts - -- SOLVENT runs margin-gated research briefs with Stripe checkout. - -## User preferences - -- (learn over time) - -## Decisions - -- (record standing choices here) diff --git a/solvent/templates/workspace/TOOLS.md b/solvent/templates/workspace/TOOLS.md deleted file mode 100644 index e27c916..0000000 --- a/solvent/templates/workspace/TOOLS.md +++ /dev/null @@ -1,42 +0,0 @@ -# TOOLS — local conventions - -Guidance only — availability is enforced by the runtime registry. - -## Business tools (chat / Telegram) - -| Tool | Use when | -|------|----------| -| `treasury_status` | Balance, revenue, margin questions | -| `list_jobs` | Recent work overview | -| `job_status` | One job + metrics | -| `quote_brief` | Margin preview before payment | -| `submit_brief` | After confirmation — creates checkout | - -## Research tools (fulfillment) - -| Tool | Use when | -|------|----------| -| `web_search` | External evidence (offline stub without live search) | -| `market_data` | Symbol snapshot | -| `summarize` | Condense notes | - -## Hermes bridge (large catalogs) - -When you see `tool_search`, `tool_describe`, `tool_call` — use them to discover and invoke other tools. - -## CLI operators run alongside chat - -```bash -python -m solvent serve # Stripe webhooks -python -m solvent worker # fulfill jobs -python -m solvent telegram # this bot -python -m solvent doctor # diagnostics -python -m solvent pairing approve TG-XXXXXX -``` - -## Environment (never paste secrets into chat) - -- `NVIDIA_API_KEY` — Nemotron live inference -- `STRIPE_API_KEY` — test checkout (`sk_test_` / `rk_test_` only) -- `TELEGRAM_BOT_TOKEN` — bot transport -- `SOLVENT_BASE_URL` — hosted brief URLs diff --git a/solvent/templates/workspace/USER.md b/solvent/templates/workspace/USER.md deleted file mode 100644 index 2b7f93f..0000000 --- a/solvent/templates/workspace/USER.md +++ /dev/null @@ -1,19 +0,0 @@ -# USER - -_Fill in who you are so SOLVENT can address you correctly._ - -- **Name:** -- **Preferred address:** (e.g. first name, handle) -- **Timezone:** -- **Email for briefs:** (default checkout contact) - -## Preferences - -- Report length: (concise / standard / deep) -- Sectors of interest: -- Budget comfort range: - -## Context - -- Why you're using SOLVENT: -- Standing instructions: diff --git a/solvent/tui.py b/solvent/tui.py deleted file mode 100644 index 039b5ba..0000000 --- a/solvent/tui.py +++ /dev/null @@ -1,282 +0,0 @@ -""" -tui.py — Live terminal dashboard for SOLVENT. - -Run with: - python -m solvent tui - -Displays treasury balance, recent jobs, and event log in a rich terminal UI -that refreshes every 2 seconds until Ctrl-C. -""" - -from __future__ import annotations - -import time -from datetime import datetime -from typing import TYPE_CHECKING - -# --------------------------------------------------------------------------- -# Rich import guard -# --------------------------------------------------------------------------- -try: - from rich.layout import Layout - from rich.live import Live - from rich.panel import Panel - from rich.table import Table - from rich.text import Text - from rich.console import Console - from rich import box - _RICH_AVAILABLE = True -except ImportError: # pragma: no cover - # Importing this module must never crash the interpreter — `rich` is an - # optional extra. Only `run()` actually needs it; report cleanly there. - _RICH_AVAILABLE = False - -if TYPE_CHECKING: - from .treasury import Treasury - -# --------------------------------------------------------------------------- -# Colour helpers -# --------------------------------------------------------------------------- - -def _status_color(status: str) -> str: - """Return a Rich colour name for a job status string.""" - return { - "completed": "green", - "failed": "red", - "in_progress": "yellow", - "awaiting_payment": "cyan", - }.get(status, "white") - - -def _margin_color(margin_pct: float) -> str: - """Return a Rich colour name based on the net-margin percentage.""" - if margin_pct >= 50: - return "green" - if margin_pct >= 10: - return "yellow" - return "red" - - -def _balance_color(balance_cents: int) -> str: - """Return a Rich colour name for the treasury balance.""" - return "green" if balance_cents > 0 else "red" - - -def _fmt(cents: int) -> str: - """Format an integer cent amount as a dollar string (e.g. $1,234.56).""" - return f"${cents / 100:,.2f}" - - -# --------------------------------------------------------------------------- -# Panel builders -# --------------------------------------------------------------------------- - -def _header_panel() -> Panel: - ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - title = Text("SOLVENT Treasury", style="bold white") - title.append(f" {ts}", style="dim white") - return Panel(title, style="bold blue", box=box.HEAVY_HEAD) - - -def _stat_box(label: str, value: str, color: str) -> Panel: - body = Text(value, style=f"bold {color}", justify="center") - return Panel(body, title=f"[dim]{label}[/dim]", border_style=color, box=box.ROUNDED) - - -def _stats_panels(snapshot: dict) -> list[Panel]: - balance = snapshot.get("balance_cents", 0) - revenue = snapshot.get("revenue_cents", 0) - margin = snapshot.get("margin_pct", 0.0) - - panels = [ - _stat_box("Balance", _fmt(balance), _balance_color(balance)), - _stat_box("Revenue", _fmt(revenue), "bright_cyan"), - _stat_box( - "Net Margin %", - f"{margin:.1f}%", - _margin_color(margin), - ), - ] - return panels - - -def _jobs_table(jobs: list[dict]) -> Table: - table = Table( - box=box.SIMPLE_HEAVY, - show_header=True, - header_style="bold magenta", - expand=True, - padding=(0, 1), - ) - table.add_column("ID", style="dim", no_wrap=True, max_width=16) - table.add_column("Topic", max_width=40) - table.add_column("Status", no_wrap=True) - table.add_column("P&L", justify="right", no_wrap=True) - - for job in jobs[-10:]: - job_id = job.get("id", "") - topic = job.get("topic") or "" - if len(topic) > 40: - topic = topic[:37] + "…" - status = job.get("status", "") - color = _status_color(status) - status_text = Text(status, style=color) - - # P&L is loaded separately when build_layout is called - pnl_cents = job.get("_pnl_cents", None) - if pnl_cents is None: - pnl_str = Text("—", style="dim") - else: - pnl_color = "green" if pnl_cents >= 0 else "red" - pnl_str = Text(_fmt(pnl_cents), style=pnl_color) - - table.add_row(job_id, topic, status_text, pnl_str) - - return table - - -def _events_table(events: list[dict]) -> Table: - table = Table( - box=box.SIMPLE, - show_header=True, - header_style="bold blue", - expand=True, - padding=(0, 1), - ) - table.add_column("Stage", no_wrap=True) - table.add_column("Job ID", style="dim", no_wrap=True) - table.add_column("Time", style="dim", no_wrap=True) - - for ev in events[:8]: - stage = ev.get("stage", "") - job_id = ev.get("job_id", "") or "" - ts = ev.get("ts", 0) - try: - ts_str = datetime.fromtimestamp(float(ts)).strftime("%H:%M:%S") - except (ValueError, OSError): - ts_str = "—" - table.add_row(stage, job_id, ts_str) - - return table - - -# --------------------------------------------------------------------------- -# Public layout builder (used directly in tests) -# --------------------------------------------------------------------------- - -def build_layout( - snapshot: dict, - jobs: list[dict], - events: list[dict], -) -> Layout: - """ - Construct and return the full Rich Layout for one refresh cycle. - - Parameters - ---------- - snapshot: - Dict returned by ``Treasury.snapshot()``. - jobs: - List of job dicts, each optionally decorated with ``_pnl_cents``. - events: - List of event dicts returned by ``Treasury.list_events()``. - """ - layout = Layout() - - layout.split_column( - Layout(name="header", size=3), - Layout(name="stats", size=5), - Layout(name="jobs", ratio=2), - Layout(name="events", ratio=1), - Layout(name="footer", size=3), - ) - - # Header - layout["header"].update(_header_panel()) - - # Stats row — three side-by-side panels - stat_panels = _stats_panels(snapshot) - stats_layout = Layout() - stats_layout.split_row( - Layout(stat_panels[0], name="balance"), - Layout(stat_panels[1], name="revenue"), - Layout(stat_panels[2], name="margin"), - ) - layout["stats"].update(stats_layout) - - # Jobs table - jobs_table = _jobs_table(jobs) - layout["jobs"].update( - Panel(jobs_table, title="[bold]Recent Jobs[/bold] (last 10)", border_style="magenta") - ) - - # Events log - ev_table = _events_table(events) - layout["events"].update( - Panel(ev_table, title="[bold]Event Log[/bold] (last 8)", border_style="blue") - ) - - # Footer hint - footer_text = Text( - " q / Ctrl-C exit · auto-refresh every 2 s", - style="dim italic", - justify="center", - ) - layout["footer"].update(Panel(footer_text, box=box.MINIMAL)) - - return layout - - -# --------------------------------------------------------------------------- -# Entry point -# --------------------------------------------------------------------------- - -def run(treasury: "Treasury | None" = None, refresh_interval: float = 2.0) -> None: - """ - Start the live terminal dashboard and block until Ctrl-C. - - Parameters - ---------- - treasury: - An existing ``Treasury`` instance. If *None*, a default treasury is - opened from the standard DB path. - refresh_interval: - How often (in seconds) to poll the database for updates. - """ - if not _RICH_AVAILABLE: - print( - "[SOLVENT] The 'rich' library is required for the terminal dashboard.\n" - "Install it with: pip install 'solvent-agent[rich]' (or: pip install rich)" - ) - return - - if treasury is None: - from .treasury import Treasury - treasury = Treasury() - - console = Console() - - def _render() -> Layout: - snapshot = treasury.snapshot() - jobs = treasury.list_jobs() - # Annotate each job with its P&L so the table renderer can display it - for job in jobs: - try: - job["_pnl_cents"] = treasury.job_pnl_cents(job["id"]) - except Exception: - job["_pnl_cents"] = None - events = treasury.list_events(limit=8) - return build_layout(snapshot, jobs, events) - - try: - with Live( - _render(), - console=console, - refresh_per_second=1 / refresh_interval, - screen=True, - ) as live: - while True: - time.sleep(refresh_interval) - live.update(_render()) - except KeyboardInterrupt: - pass diff --git a/solvent/upgrade.py b/solvent/upgrade.py index b62838a..2d91b79 100644 --- a/solvent/upgrade.py +++ b/solvent/upgrade.py @@ -6,10 +6,10 @@ solvent upgrade --check exit 1 if current version is behind PyPI (CI-friendly) solvent upgrade --install run pip install to upgrade in-place -Background hint: call background_update_hint() at startup to fire a -non-blocking daemon thread that prints a one-line upgrade notice to stderr -if outdated. Rate-limited to once per day; skipped when -SOLVENT_NO_UPDATE_CHECK=1 is set. +Background hint: call background_update_hint() to fire a non-blocking +daemon thread that prints a one-line upgrade notice to stderr if outdated. +Rate-limited to once per day. The demo CLI only calls this when opted in +via SOLVENT_UPDATE_CHECK=1 (it is off by default). """ from __future__ import annotations diff --git a/solvent/workspace.py b/solvent/workspace.py index 14b0b6a..96f5382 100644 --- a/solvent/workspace.py +++ b/solvent/workspace.py @@ -5,8 +5,8 @@ injected into the system prompt each session, following: - Hermes: SOUL.md is slot #1 (identity), AGENTS.md is project context -- OpenClaw: SOUL, IDENTITY, USER, TOOLS, AGENTS, MEMORY, HEARTBEAT, daily logs - Community: BRAIN.md for active business state (read every session) +- OpenClaw: daily memory logs + an optional MEMORY.md for durable facts """ from __future__ import annotations @@ -30,22 +30,14 @@ BOOTSTRAP_FILES = ( "SOUL.md", - "IDENTITY.md", "AGENTS.md", - "USER.md", - "TOOLS.md", "BRAIN.md", - "HEARTBEAT.md", - "MEMORY.md", - "BOOTSTRAP.md", ) # Hermes/OpenClaw injection order for project context CONTEXT_FILES = ( "BRAIN.md", "AGENTS.md", - "TOOLS.md", - "USER.md", ) SOLVENT_CORE_RULES = ( @@ -186,10 +178,6 @@ def load_soul() -> str: return content or _missing_marker("SOUL.md") -def load_identity() -> str | None: - return _read_file(workspace_path() / "IDENTITY.md") - - def load_context_file(name: str) -> str | None: return _read_file(workspace_path() / name) @@ -234,7 +222,6 @@ def build_system_prompt( *, session_kind: str = "main", include_memory: bool = True, - include_heartbeat: bool = False, ) -> str: """ Compose the chat system prompt (Hermes prompt assembly + OpenClaw workspace). @@ -257,10 +244,6 @@ def add_block(text: str) -> None: # Slot 1: SOUL (identity — verbatim, Hermes) add_block(load_soul()) - identity = load_identity() - if identity: - add_block(f"# Identity\n\n{identity}") - add_block(SOLVENT_CORE_RULES) context_sections: list[str] = [] @@ -291,20 +274,6 @@ def add_block(text: str) -> None: if skills: add_block(skills) - if include_heartbeat: - hb = load_context_file("HEARTBEAT.md") - if hb: - add_block(f"# Heartbeat Checklist\n\n{hb}") - - bootstrap = load_context_file("BOOTSTRAP.md") - if bootstrap and "[complete]" not in bootstrap.lower(): - add_block( - "# Bootstrap Ritual\n\n" - "Complete the first-run ritual in BOOTSTRAP.md, then delete the file or " - "mark it complete.\n\n" - + bootstrap - ) - return "\n\n".join(parts) diff --git a/tests/test_chat_tools.py b/tests/test_chat_tools.py index a0f076c..88c23ac 100644 --- a/tests/test_chat_tools.py +++ b/tests/test_chat_tools.py @@ -1,3 +1,4 @@ +import os import tempfile import unittest from pathlib import Path @@ -50,7 +51,6 @@ def test_handle_message_treasury_tool(self, mock_complete): def test_per_turn_tool_budget(self, mock_complete): """A single reply with many tool calls is capped at the per-turn budget.""" import solvent.chat as chatmod - from solvent.hermes_tools import ToolRegistry many = " ".join( '{"name": "treasury_status", "arguments": {}}' @@ -59,13 +59,18 @@ def test_per_turn_tool_budget(self, mock_complete): mock_complete.side_effect = [(many, {}), ("All set.", {})] calls = {"n": 0} - orig = ToolRegistry.dispatch + orig = chatmod._make_executor - def counting(self, name, args): - calls["n"] += 1 - return orig(self, name, args) + def counting_executor(agent, session_id, live_search): + run = orig(agent, session_id, live_search) - with patch.object(ToolRegistry, "dispatch", counting), \ + def counting_run(name, args): + calls["n"] += 1 + return run(name, args) + + return counting_run + + with patch.object(chatmod, "_make_executor", counting_executor), \ patch.object(chatmod.tools, "MAX_TOOL_CALLS", 5): reply = handle_message( self.session["id"], "spam tools", agent=self.agent, memory=self.memory @@ -84,10 +89,11 @@ def test_submit_brief_via_tool(self, mock_complete): ), ("Checkout link sent.", {}), ] - reply = handle_message( - self.session["id"], - "Submit brief on EV market budget 50 email c@test.com", - agent=self.agent, - memory=self.memory, - ) + with patch.dict(os.environ, {"SOLVENT_DELIVERY_SECRET": "x" * 32}): + reply = handle_message( + self.session["id"], + "Submit brief on EV market budget 50 email c@test.com", + agent=self.agent, + memory=self.memory, + ) self.assertTrue("Checkout" in reply or "invoice" in reply.lower() or len(reply) > 0) diff --git a/tests/test_hermes_tools.py b/tests/test_hermes_tools.py deleted file mode 100644 index 450a47b..0000000 --- a/tests/test_hermes_tools.py +++ /dev/null @@ -1,34 +0,0 @@ -import json -import unittest - -from solvent.hermes_tools import ToolRegistry, DISCLOSURE_THRESHOLD - - -class TestHermesTools(unittest.TestCase): - def _executor(self, name: str, args: dict) -> str: - return json.dumps({"tool": name, "args": args}) - - def test_direct_mode_small_catalog(self): - tools = {f"t{i}": {"description": f"tool {i}", "category": "x", "parameters": {}} for i in range(5)} - reg = ToolRegistry(tools, self._executor) - visible = reg.visible_tools() - self.assertNotIn("tool_search", visible) - self.assertEqual(len(visible), 5) - - def test_bridge_mode_large_catalog(self): - tools = { - f"t{i}": {"description": f"tool {i}", "category": "x", "parameters": {}} - for i in range(DISCLOSURE_THRESHOLD + 2) - } - reg = ToolRegistry(tools, self._executor) - self.assertIn("tool_search", reg.visible_tools()) - found = json.loads(reg.dispatch("tool_search", {"query": "t1"})) - self.assertTrue(any(x["name"] == "t1" for x in found)) - - def test_tool_call_dispatch(self): - reg = ToolRegistry( - {"echo": {"description": "echo", "category": "test", "parameters": {"x": "string"}}}, - self._executor, - ) - out = json.loads(reg.dispatch("echo", {"x": "hi"})) - self.assertEqual(out["tool"], "echo") diff --git a/tests/test_improver.py b/tests/test_improver.py deleted file mode 100644 index 27c0c2c..0000000 --- a/tests/test_improver.py +++ /dev/null @@ -1,28 +0,0 @@ -"""Tests for auto-improvement tuner.""" - -import unittest - -from solvent.improver import analyze_metrics, propose_changes -from solvent.treasury import Treasury - - -class TestImprover(unittest.TestCase): - def test_not_ready_with_few_jobs(self): - t = Treasury() - t.reset() - analysis = analyze_metrics(t) - self.assertFalse(analysis["ready"]) - - def test_proposes_pricing_when_margin_negative(self): - proposals = propose_changes({ - "ready": True, - "avg_margin_error": -8, - "refund_rate": 0.05, - "avg_fulfillment_seconds": 60, - }) - kinds = [p["type"] for p in proposals] - self.assertIn("pricing", kinds) - - -if __name__ == "__main__": - unittest.main() diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 5a24458..9fefdc4 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -29,7 +29,7 @@ def test_console_script_and_extras_declared(self): # core has no hard deps; functionality is opt-in via extras self.assertEqual(data["project"]["dependencies"], []) extras = data["project"]["optional-dependencies"] - for name in ("serve", "telegram", "stripe", "rich", "dev", "all"): + for name in ("serve", "telegram", "stripe", "qr", "dev", "all"): self.assertIn(name, extras) @unittest.skipIf(tomllib is None, "tomllib requires Python 3.11+") diff --git a/tests/test_retry.py b/tests/test_retry.py index b23c9b3..c843b26 100644 --- a/tests/test_retry.py +++ b/tests/test_retry.py @@ -8,7 +8,7 @@ from pathlib import Path from unittest import mock -from solvent.stages import StageRunner, SolventStages +from solvent.stages import StageRunner from solvent.treasury import Treasury from solvent.guardrails import Guardrails from solvent.stripe_client import StripeClient @@ -297,12 +297,5 @@ def test_retry_count_increments(self): self.assertEqual(row["retry_count"], expected_count) -class TestSolventStagesAlias(unittest.TestCase): - """SolventStages must be importable and be the same class as StageRunner.""" - - def test_alias_is_stagerunner(self): - self.assertIs(SolventStages, StageRunner) - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_tui.py b/tests/test_tui.py deleted file mode 100644 index c6b6c53..0000000 --- a/tests/test_tui.py +++ /dev/null @@ -1,235 +0,0 @@ -"""tests/test_tui.py — unit tests for the rich terminal dashboard.""" - -from __future__ import annotations - -import time -from unittest.mock import MagicMock, patch - -import pytest - -# `rich` is an optional extra; skip the whole module when it isn't installed. -pytest.importorskip("rich") - - -# --------------------------------------------------------------------------- -# Helpers: build minimal fake data that mirrors Treasury outputs -# --------------------------------------------------------------------------- - -SNAPSHOT = { - "balance_cents": 5000, - "capital_cents": 10000, - "revenue_cents": 8000, - "expense_cents": 3000, - "net_profit_cents": 5000, - "margin_pct": 62.5, - "entries": [], -} - -JOBS = [ - { - "id": "job_001", - "topic": "Nvidia Q3 earnings deep-dive for institutional investors", - "status": "completed", - "_pnl_cents": 1200, - "created_at": time.time() - 60, - "updated_at": time.time(), - }, - { - "id": "job_002", - "topic": "This topic is exactly forty characters long!!! and then some more text", - "status": "failed", - "_pnl_cents": -300, - "created_at": time.time() - 30, - "updated_at": time.time(), - }, - { - "id": "job_003", - "topic": "Short topic", - "status": "in_progress", - "_pnl_cents": None, - "created_at": time.time(), - "updated_at": time.time(), - }, - { - "id": "job_004", - "topic": "Awaiting payment research", - "status": "awaiting_payment", - "_pnl_cents": 0, - "created_at": time.time(), - "updated_at": time.time(), - }, -] - -EVENTS = [ - {"id": "ev_001", "job_id": "job_001", "stage": "quote", "ts": time.time() - 90}, - {"id": "ev_002", "job_id": "job_001", "stage": "paid", "ts": time.time() - 60}, - {"id": "ev_003", "job_id": "job_001", "stage": "fulfilled", "ts": time.time() - 30}, - {"id": "ev_004", "job_id": "job_002", "stage": "declined", "ts": time.time()}, -] - - -# --------------------------------------------------------------------------- -# Tests: build_layout -# --------------------------------------------------------------------------- - -def test_build_layout_returns_layout(): - """build_layout should return a rich Layout without raising.""" - from rich.layout import Layout - from solvent.tui import build_layout - - result = build_layout(SNAPSHOT, JOBS, EVENTS) - assert isinstance(result, Layout) - - -def test_build_layout_with_empty_data(): - """build_layout should handle empty jobs / events gracefully.""" - from rich.layout import Layout - from solvent.tui import build_layout - - empty_snap = { - "balance_cents": 0, - "revenue_cents": 0, - "margin_pct": 0.0, - } - result = build_layout(empty_snap, [], []) - assert isinstance(result, Layout) - - -def test_build_layout_truncates_long_topics(): - """Topics longer than 40 chars should be truncated with an ellipsis.""" - from rich.layout import Layout - from solvent.tui import build_layout - - long_topic_job = [{"id": "x", "topic": "A" * 80, "status": "completed", "_pnl_cents": 0}] - # Should not raise; the table cell will contain a truncated string - result = build_layout(SNAPSHOT, long_topic_job, []) - assert isinstance(result, Layout) - - -# --------------------------------------------------------------------------- -# Tests: status colour mapping -# --------------------------------------------------------------------------- - -def test_status_color_completed(): - from solvent.tui import _status_color - assert _status_color("completed") == "green" - - -def test_status_color_failed(): - from solvent.tui import _status_color - assert _status_color("failed") == "red" - - -def test_status_color_in_progress(): - from solvent.tui import _status_color - assert _status_color("in_progress") == "yellow" - - -def test_status_color_awaiting_payment(): - from solvent.tui import _status_color - assert _status_color("awaiting_payment") == "cyan" - - -def test_status_color_unknown_fallback(): - from solvent.tui import _status_color - assert _status_color("some_unknown_status") == "white" - - -# --------------------------------------------------------------------------- -# Tests: margin colour mapping -# --------------------------------------------------------------------------- - -def test_margin_color_high(): - from solvent.tui import _margin_color - assert _margin_color(75.0) == "green" - - -def test_margin_color_boundary_50(): - from solvent.tui import _margin_color - assert _margin_color(50.0) == "green" - - -def test_margin_color_mid(): - from solvent.tui import _margin_color - assert _margin_color(25.0) == "yellow" - - -def test_margin_color_boundary_10(): - from solvent.tui import _margin_color - assert _margin_color(10.0) == "yellow" - - -def test_margin_color_low(): - from solvent.tui import _margin_color - assert _margin_color(5.0) == "red" - - -def test_margin_color_zero(): - from solvent.tui import _margin_color - assert _margin_color(0.0) == "red" - - -# --------------------------------------------------------------------------- -# Tests: balance colour mapping -# --------------------------------------------------------------------------- - -def test_balance_color_positive(): - from solvent.tui import _balance_color - assert _balance_color(100) == "green" - - -def test_balance_color_zero(): - from solvent.tui import _balance_color - assert _balance_color(0) == "red" - - -def test_balance_color_negative(): - from solvent.tui import _balance_color - assert _balance_color(-50) == "red" - - -# --------------------------------------------------------------------------- -# Tests: run() — mock Live so it does not block -# --------------------------------------------------------------------------- - -def test_run_exits_on_keyboard_interrupt(): - """run() should return cleanly when KeyboardInterrupt is raised.""" - from solvent.treasury import Treasury - - # Build an in-memory treasury so no DB file is needed - treasury = Treasury(path="/tmp/solvent_tui_test.db") - - mock_live_instance = MagicMock() - mock_live_instance.__enter__ = MagicMock(return_value=mock_live_instance) - mock_live_instance.__exit__ = MagicMock(return_value=False) - - # Make sleep raise KeyboardInterrupt immediately - with patch("solvent.tui.Live", return_value=mock_live_instance), \ - patch("solvent.tui.time.sleep", side_effect=KeyboardInterrupt): - from solvent.tui import run - run(treasury=treasury, refresh_interval=0.01) # should not raise - - -def test_run_calls_live_update(): - """run() should call live.update() at least once per sleep cycle.""" - from solvent.treasury import Treasury - - treasury = Treasury(path="/tmp/solvent_tui_test2.db") - - call_count = {"n": 0} - - def fake_sleep(_): - call_count["n"] += 1 - if call_count["n"] >= 2: - raise KeyboardInterrupt - - mock_live_instance = MagicMock() - mock_live_instance.__enter__ = MagicMock(return_value=mock_live_instance) - mock_live_instance.__exit__ = MagicMock(return_value=False) - - with patch("solvent.tui.Live", return_value=mock_live_instance), \ - patch("solvent.tui.time.sleep", side_effect=fake_sleep): - from solvent.tui import run - run(treasury=treasury, refresh_interval=0.01) - - assert mock_live_instance.update.call_count >= 1 diff --git a/tests/test_tui_optional.py b/tests/test_tui_optional.py deleted file mode 100644 index f927f86..0000000 --- a/tests/test_tui_optional.py +++ /dev/null @@ -1,28 +0,0 @@ -"""tui must import without `rich` installed and degrade cleanly. - -These tests intentionally do NOT importorskip rich — importing the module and -calling run() must work (or fail gracefully) regardless of whether the -optional extra is present. -""" - -import io -import unittest -from contextlib import redirect_stdout -from unittest.mock import patch - -from solvent import tui - - -class TestTuiOptional(unittest.TestCase): - def test_import_does_not_crash(self): - self.assertTrue(callable(tui.run)) - - def test_run_degrades_when_rich_missing(self): - buf = io.StringIO() - with patch.object(tui, "_RICH_AVAILABLE", False), redirect_stdout(buf): - tui.run() # must return cleanly, not raise/exit - self.assertIn("rich", buf.getvalue().lower()) - - -if __name__ == "__main__": - unittest.main()