solvent-agent 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. solvent/__init__.py +3 -0
  2. solvent/__main__.py +172 -0
  3. solvent/agent.py +90 -0
  4. solvent/channels/__init__.py +1 -0
  5. solvent/channels/telegram.py +104 -0
  6. solvent/chat.py +279 -0
  7. solvent/cli.py +354 -0
  8. solvent/config.py +124 -0
  9. solvent/config_cmd.py +151 -0
  10. solvent/dashboard.py +1496 -0
  11. solvent/dashboard_chat.py +316 -0
  12. solvent/delivery.py +398 -0
  13. solvent/doctor.py +132 -0
  14. solvent/event_hub.py +49 -0
  15. solvent/finance.py +424 -0
  16. solvent/gateway.py +189 -0
  17. solvent/guardrails.py +195 -0
  18. solvent/init.py +146 -0
  19. solvent/job_cmd.py +258 -0
  20. solvent/jobs.py +60 -0
  21. solvent/logs.py +211 -0
  22. solvent/memory.py +26 -0
  23. solvent/nemotron.py +418 -0
  24. solvent/notifications.py +69 -0
  25. solvent/observability.py +54 -0
  26. solvent/onboarding.py +234 -0
  27. solvent/pairing.py +94 -0
  28. solvent/paths.py +63 -0
  29. solvent/pricing.py +152 -0
  30. solvent/py.typed +0 -0
  31. solvent/qr.py +62 -0
  32. solvent/queue.py +37 -0
  33. solvent/rate_limit.py +187 -0
  34. solvent/receipt.py +129 -0
  35. solvent/reconcile.py +97 -0
  36. solvent/security.py +427 -0
  37. solvent/server.py +454 -0
  38. solvent/service.py +119 -0
  39. solvent/stages.py +694 -0
  40. solvent/status.py +194 -0
  41. solvent/stripe_client.py +519 -0
  42. solvent/templates/workspace/AGENTS.md +34 -0
  43. solvent/templates/workspace/BRAIN.md +26 -0
  44. solvent/templates/workspace/SOUL.md +33 -0
  45. solvent/templates/workspace/skills/commission-research/SKILL.md +23 -0
  46. solvent/tools.py +195 -0
  47. solvent/treasury.py +900 -0
  48. solvent/upgrade.py +171 -0
  49. solvent/webhook_log.py +158 -0
  50. solvent/worker.py +56 -0
  51. solvent/workspace.py +342 -0
  52. solvent_agent-0.1.0.dist-info/METADATA +422 -0
  53. solvent_agent-0.1.0.dist-info/RECORD +57 -0
  54. solvent_agent-0.1.0.dist-info/WHEEL +5 -0
  55. solvent_agent-0.1.0.dist-info/entry_points.txt +2 -0
  56. solvent_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
  57. solvent_agent-0.1.0.dist-info/top_level.txt +1 -0
solvent/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """SOLVENT — a self-funding analyst agent. See run_demo.py."""
2
+
3
+ __version__ = "0.1.0"
solvent/__main__.py ADDED
@@ -0,0 +1,172 @@
1
+ """SOLVENT CLI entry point: python3 -m solvent.
2
+
3
+ Commands: [serve|worker|telegram|doctor|pairing|...]
4
+ """
5
+
6
+ import sys
7
+
8
+ from . import __version__
9
+
10
+ HELP = """\
11
+ SOLVENT — a self-funding analyst agent.
12
+
13
+ Usage: solvent <command> [options]
14
+ solvent run the demo (interactive onboarding on first run)
15
+
16
+ Commands:
17
+ (none) run the batch demo / interactive session
18
+ init first-run setup: create dirs, DB, and workspace files
19
+ status live summary: balance, jobs, API key presence; --watch to auto-refresh
20
+ upgrade check for newer version on PyPI; --check exits 1 if outdated
21
+ jobs list/show/retry/cancel jobs (jobs --help for sub-commands)
22
+ logs tail the structured event log; -f to follow, --job/--stage to filter
23
+ config show/get/set/reset local configuration values
24
+ serve webhooks + job API + hosted briefs
25
+ worker resume incomplete jobs, process the queue
26
+ telegram long-poll the Telegram bot
27
+ finance income statement, unit economics, runway, forecast (alias: report)
28
+ reconcile Stripe <-> ledger drift check
29
+ doctor stack diagnostics
30
+ pairing manage Telegram DM pairing codes
31
+ workspace seed the agent workspace (SOUL/BRAIN/AGENTS)
32
+ retry <id> re-run a stuck job
33
+ webhooks inspect the webhook event log (stats|list|failed)
34
+ help show this message
35
+ version print the installed version
36
+
37
+ Run `solvent <command> --help` where supported for command-specific options.
38
+ """
39
+
40
+
41
+ def _print_version() -> None:
42
+ print(f"solvent {__version__}")
43
+
44
+
45
+ def main() -> None:
46
+ if len(sys.argv) > 1 and sys.argv[1] in ("version", "--version", "-V"):
47
+ _print_version()
48
+ return
49
+ if len(sys.argv) > 1 and sys.argv[1] in ("help", "--help", "-h"):
50
+ print(HELP)
51
+ return
52
+ if len(sys.argv) > 1 and sys.argv[1] == "init":
53
+ sys.argv.pop(1)
54
+ from .init import main as init_main
55
+
56
+ init_main()
57
+ elif len(sys.argv) > 1 and sys.argv[1] == "status":
58
+ sys.argv.pop(1)
59
+ from .status import main as status_main
60
+
61
+ status_main()
62
+ elif len(sys.argv) > 1 and sys.argv[1] == "jobs":
63
+ sys.argv.pop(1)
64
+ from .job_cmd import main as jobs_main
65
+
66
+ jobs_main()
67
+ elif len(sys.argv) > 1 and sys.argv[1] == "upgrade":
68
+ sys.argv.pop(1)
69
+ from .upgrade import main as upgrade_main
70
+
71
+ upgrade_main()
72
+ elif len(sys.argv) > 1 and sys.argv[1] == "logs":
73
+ sys.argv.pop(1)
74
+ from .logs import main as logs_main
75
+
76
+ logs_main()
77
+ elif len(sys.argv) > 1 and sys.argv[1] == "config":
78
+ sys.argv.pop(1)
79
+ from .config_cmd import main as config_main
80
+
81
+ config_main()
82
+ elif len(sys.argv) > 1 and sys.argv[1] == "serve":
83
+ sys.argv.pop(1)
84
+ from .server import main as serve_main
85
+
86
+ serve_main()
87
+ elif len(sys.argv) > 1 and sys.argv[1] == "worker":
88
+ sys.argv.pop(1)
89
+ from .worker import main as worker_main
90
+
91
+ worker_main()
92
+ elif len(sys.argv) > 1 and sys.argv[1] == "reconcile":
93
+ sys.argv.pop(1)
94
+ from .reconcile import main as reconcile_main
95
+
96
+ reconcile_main()
97
+ elif len(sys.argv) > 1 and sys.argv[1] == "doctor":
98
+ sys.argv.pop(1)
99
+ from .doctor import main as doctor_main
100
+
101
+ doctor_main()
102
+ elif len(sys.argv) > 1 and sys.argv[1] == "telegram":
103
+ sys.argv.pop(1)
104
+ from .channels.telegram import main as telegram_main
105
+
106
+ telegram_main()
107
+ elif len(sys.argv) > 1 and sys.argv[1] == "pairing":
108
+ sys.argv.pop(1)
109
+ from .pairing import main as pairing_main
110
+
111
+ pairing_main()
112
+ elif len(sys.argv) > 1 and sys.argv[1] == "workspace":
113
+ sys.argv.pop(1)
114
+ from .workspace import main as workspace_main
115
+
116
+ workspace_main()
117
+ elif len(sys.argv) > 1 and sys.argv[1] == "retry":
118
+ job_id = sys.argv[2] if len(sys.argv) > 2 else None
119
+ if not job_id:
120
+ print("Usage: python -m solvent retry <job_id>")
121
+ sys.exit(1)
122
+ from .guardrails import Guardrails
123
+ from .stages import StageRunner
124
+ from .stripe_client import StripeClient
125
+ from .treasury import Treasury
126
+
127
+ t = Treasury()
128
+ s = StageRunner(treasury=t, guard=Guardrails(t), stripe=StripeClient())
129
+ result = s.retry_job(job_id)
130
+ import json
131
+
132
+ print(json.dumps(result, indent=2, default=str))
133
+ elif len(sys.argv) > 1 and sys.argv[1] in ("finance", "report"):
134
+ sys.argv.pop(1)
135
+ from .finance import main as finance_main
136
+
137
+ finance_main()
138
+ elif len(sys.argv) > 1 and sys.argv[1] == "webhooks":
139
+ import json
140
+
141
+ from .webhook_log import WebhookLog
142
+
143
+ wl = WebhookLog()
144
+ sub = sys.argv[2] if len(sys.argv) > 2 else "stats"
145
+ if sub == "stats":
146
+ print(json.dumps(wl.stats(), indent=2))
147
+ elif sub == "list":
148
+ for row in wl.list_recent(20):
149
+ print(
150
+ f"{row['received_at_fmt']} [{row['status']}] {row['event_type']} "
151
+ f"{row['event_id'][:16]}"
152
+ )
153
+ elif sub == "failed":
154
+ for row in wl.list_failed():
155
+ print(f" {row['event_id'][:16]} {row['event_type']} err={row['error'][:60]}")
156
+ else:
157
+ # No subcommand: fall through to the demo / interactive CLI.
158
+ # Update checks are opt-in only (SOLVENT_UPDATE_CHECK=1) — run
159
+ # `solvent upgrade` explicitly to check for a newer version.
160
+ import os
161
+
162
+ if os.environ.get("SOLVENT_UPDATE_CHECK", "").strip() in ("1", "true", "yes"):
163
+ from .upgrade import background_update_hint
164
+
165
+ background_update_hint()
166
+ from .cli import main as demo_main
167
+
168
+ demo_main()
169
+
170
+
171
+ if __name__ == "__main__":
172
+ main()
solvent/agent.py ADDED
@@ -0,0 +1,90 @@
1
+ """
2
+ agent.py — the SOLVENT orchestrator.
3
+
4
+ For each inbound job the agent runs an idempotent stage machine:
5
+ 1. QUOTES through the margin gate
6
+ 2. EARNS via Stripe Checkout (webhook-first; sync confirm in demo mode)
7
+ 3. FULFILS via bounded Nemotron tool-calling
8
+ 4. DELIVERS hosted brief + optional email
9
+ 5. SPENDS on vendors (guardrail-screened)
10
+ 6. BOOKS P&L into the treasury
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import os
16
+ import time
17
+ from collections.abc import Callable
18
+
19
+ from .guardrails import Guardrails
20
+ from .pricing import PricingPolicy
21
+ from .stages import StageRunner, validate_and_coerce_job
22
+ from .stripe_client import StripeClient
23
+ from .treasury import Treasury
24
+
25
+
26
+ class Solvent:
27
+ def __init__(
28
+ self,
29
+ seed_cents: int = 10_000,
30
+ fresh: bool = True,
31
+ on_event: Callable | None = None,
32
+ *,
33
+ sync_payment: bool | None = None,
34
+ ):
35
+ self.t = Treasury()
36
+ if fresh:
37
+ self.t.reset()
38
+ self.t.seed(seed_cents)
39
+ self.guard = Guardrails(self.t)
40
+ self.stripe = StripeClient()
41
+ self.pricing = PricingPolicy()
42
+ self.log: list[dict] = []
43
+ self.on_event = on_event
44
+ if sync_payment is None:
45
+ async_flag = os.environ.get("SOLVENT_ASYNC", "").strip()
46
+ sync_payment = async_flag not in ("1", "true", "yes")
47
+ self._runner = StageRunner(
48
+ self.t,
49
+ self.guard,
50
+ self.stripe,
51
+ self.pricing,
52
+ on_event=self._capture_event,
53
+ sync_payment=sync_payment,
54
+ )
55
+
56
+ def _capture_event(self, event: dict):
57
+ self.log.append(event)
58
+ if self.on_event:
59
+ self.on_event(event)
60
+
61
+ def _emit(self, **event):
62
+ if "ts" not in event:
63
+ event["ts"] = time.time()
64
+ self.log.append(event)
65
+ if self.on_event:
66
+ self.on_event(event)
67
+ return event
68
+
69
+ def handle_job(self, job: dict) -> dict:
70
+ return self._runner.run_job(job)
71
+
72
+ def advance_job(self, job_id: str) -> dict:
73
+ return self._runner.advance_job(job_id)
74
+
75
+ def enqueue_job(self, job: dict) -> dict:
76
+ """Validate and persist a job for async worker processing."""
77
+ validated, err = validate_and_coerce_job(job, self.t)
78
+ if err:
79
+ job_id = validated.get("id", "unknown") if validated else "unknown"
80
+ return self._emit(stage="declined", job_id=job_id, reason=err)
81
+ assert validated is not None
82
+ q = self._runner._stage_quote(validated)
83
+ if q.get("stage") == "declined" or not q.get("accept"):
84
+ return q
85
+ return self._runner._stage_checkout(validated, q)
86
+
87
+ def run(self, jobs: list[dict]) -> dict:
88
+ for job in jobs:
89
+ self.handle_job(job)
90
+ return self.t.snapshot()
@@ -0,0 +1 @@
1
+ """Channel adapters (OpenClaw-style)."""
@@ -0,0 +1,104 @@
1
+ """Telegram channel adapter (OpenClaw long-poll pattern)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import urllib.request
9
+ from typing import TYPE_CHECKING
10
+
11
+ from ..gateway import Gateway, register_outbound
12
+
13
+ if TYPE_CHECKING: # pragma: no cover - typing-only imports
14
+ from telegram import Update
15
+ from telegram.ext import ContextTypes
16
+
17
+
18
+ def send_telegram_message(external_id: str, text: str) -> None:
19
+ """Sync outbound via Telegram HTTP API (safe from worker threads)."""
20
+ token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
21
+ if not token:
22
+ return
23
+ url = f"https://api.telegram.org/bot{token}/sendMessage"
24
+ payload = json.dumps({"chat_id": int(external_id), "text": text[:4000]}).encode()
25
+ headers = {"Content-Type": "application/json"}
26
+ req = urllib.request.Request(url, data=payload, headers=headers)
27
+ try:
28
+ urllib.request.urlopen(req, timeout=15)
29
+ except Exception as exc:
30
+ logging.getLogger(__name__).warning("telegram send failed: %s", exc)
31
+
32
+
33
+ def _require_ptb():
34
+ """Import the runtime symbols we need from python-telegram-bot.
35
+
36
+ ``Update``/``ContextTypes`` are only needed for annotations, so they are
37
+ imported under ``TYPE_CHECKING`` instead of being returned here.
38
+ """
39
+ try:
40
+ from telegram.ext import (
41
+ Application,
42
+ CommandHandler,
43
+ MessageHandler,
44
+ filters,
45
+ )
46
+
47
+ return (
48
+ Application,
49
+ CommandHandler,
50
+ MessageHandler,
51
+ filters,
52
+ )
53
+ except ImportError as exc:
54
+ raise RuntimeError(
55
+ 'python-telegram-bot required. Install: pip install -e ".[telegram]"'
56
+ ) from exc
57
+
58
+
59
+ async def _reply(update: Update, text: str) -> None:
60
+ if update.message:
61
+ await update.message.reply_text(text[:4000])
62
+
63
+
64
+ def build_application(gateway: Gateway | None = None) -> object:
65
+ (
66
+ Application,
67
+ CommandHandler,
68
+ MessageHandler,
69
+ filters,
70
+ ) = _require_ptb()
71
+ token = os.environ.get("TELEGRAM_BOT_TOKEN", "").strip()
72
+ if not token:
73
+ raise RuntimeError("TELEGRAM_BOT_TOKEN not set")
74
+
75
+ gw = gateway or Gateway()
76
+ register_outbound("telegram", send_telegram_message)
77
+
78
+ async def on_message(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
79
+ if not update.message or not update.effective_user:
80
+ return
81
+ user = update.effective_user
82
+ text = update.message.text or ""
83
+ username = user.username
84
+ reply = gw.handle_inbound("telegram", str(user.id), text, user_label=username)
85
+ await _reply(update, reply)
86
+
87
+ app = Application.builder().token(token).build()
88
+ app.add_handler(CommandHandler("start", on_message))
89
+ app.add_handler(CommandHandler("help", on_message))
90
+ app.add_handler(CommandHandler("status", on_message))
91
+ app.add_handler(CommandHandler("jobs", on_message))
92
+ app.add_handler(CommandHandler("quote", on_message))
93
+ app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, on_message))
94
+ return app
95
+
96
+
97
+ def main():
98
+ app = build_application()
99
+ print("SOLVENT Telegram bot starting (long-poll)...")
100
+ app.run_polling(allowed_updates=["message"])
101
+
102
+
103
+ if __name__ == "__main__":
104
+ main()
solvent/chat.py ADDED
@@ -0,0 +1,279 @@
1
+ """Conversational harness + business tools for gateway channels."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import logging
7
+ import os
8
+ import re
9
+ import uuid
10
+
11
+ from . import nemotron, tools
12
+ from .agent import Solvent
13
+ from .memory import SessionMemory
14
+ from .pricing import quote
15
+ from .security import InputValidationError, PromptInjectionError, sanitise_prompt_input
16
+ from .treasury import fmt
17
+ from .workspace import build_chat_system_prompt, ensure_workspace
18
+
19
+ _EMAIL_RE = re.compile(r"[\w.+-]+@[\w.-]+\.\w+")
20
+ _BUDGET_RE = re.compile(
21
+ r"(?:budget|pay|spend)\s*[:\\$]?\s*\$?(\d+(?:\.\d{1,2})?)",
22
+ re.IGNORECASE,
23
+ )
24
+
25
+
26
+ def _new_chat_job_id(agent: Solvent) -> str:
27
+ """Generate a server-owned chat job ID that does not overwrite an existing job."""
28
+ for _ in range(10):
29
+ job_id = "T" + uuid.uuid4().hex[:8]
30
+ if not agent.t.get_job(job_id):
31
+ return job_id
32
+ return "T" + uuid.uuid4().hex
33
+
34
+
35
+ def _make_executor(agent: Solvent, session_id: str, live_search: bool):
36
+ ctx = tools.ToolContext()
37
+
38
+ def run(name: str, args: dict) -> str:
39
+ if name == "treasury_status":
40
+ s = agent.t.snapshot()
41
+ return json.dumps(
42
+ {
43
+ "balance_cents": s["balance_cents"],
44
+ "revenue_cents": s["revenue_cents"],
45
+ "expense_cents": s["expense_cents"],
46
+ "margin_pct": s["margin_pct"],
47
+ }
48
+ )
49
+ if name == "list_jobs":
50
+ jobs = agent.t.list_jobs_for_session(session_id)[-10:]
51
+ return json.dumps(
52
+ [{"id": j["id"], "status": j.get("status"), "topic": j.get("topic")} for j in jobs]
53
+ )
54
+ if name == "job_status":
55
+ jid = args.get("job_id", "")
56
+ row = agent.t.get_job_for_session(jid, session_id)
57
+ if not row:
58
+ return json.dumps({"error": "job not found"})
59
+ metrics = agent.t.get_metrics(jid)
60
+ return json.dumps({"job": row, "metrics": metrics})
61
+ if name == "quote_brief":
62
+ job = {
63
+ "id": "Q-preview",
64
+ "topic": args.get("topic", ""),
65
+ "budget_cents": int(args.get("budget_cents", 0)),
66
+ "est_tokens": 8000,
67
+ "market_data_calls": 2,
68
+ "web_search_calls": 6,
69
+ }
70
+ q = quote(job, agent.pricing)
71
+ return json.dumps(
72
+ {
73
+ "accept": q.accept,
74
+ "price_cents": q.price_cents,
75
+ "est_cost_cents": q.est_cost_cents,
76
+ "margin_pct": q.margin_pct,
77
+ "reason": q.reason,
78
+ }
79
+ )
80
+ if name == "submit_brief":
81
+ topic = args.get("topic", "")
82
+ budget = int(args.get("budget_cents", 0))
83
+ email = args.get("customer_email", "client@example.com")
84
+ job_id = _new_chat_job_id(agent)
85
+ job = {
86
+ "id": job_id,
87
+ "topic": topic,
88
+ "budget_cents": budget,
89
+ "customer_email": email,
90
+ "est_tokens": 8000,
91
+ "market_data_calls": 2,
92
+ "web_search_calls": 6,
93
+ "context": f"Commissioned via chat session {session_id}",
94
+ "job_owner_session_id": session_id,
95
+ }
96
+ result = agent.enqueue_job(job)
97
+ if result.get("stage") != "declined" and not result.get("error"):
98
+ agent.t.update_chat_session(session_id, notify_job_id=job_id, pending_job_json="")
99
+ if result.get("url"):
100
+ return json.dumps(
101
+ {
102
+ "job_id": job_id,
103
+ "checkout_url": result.get("url"),
104
+ "stage": result.get("stage", "invoice"),
105
+ }
106
+ )
107
+ return json.dumps(result)
108
+ if name in tools.ALLOWED_TOOLS:
109
+ return tools.dispatch(
110
+ name,
111
+ args,
112
+ ctx,
113
+ lambda s, u: nemotron.complete(s, u)[0],
114
+ live_search=live_search,
115
+ )
116
+ return json.dumps({"error": f"unknown tool {name!r}"})
117
+
118
+ return run
119
+
120
+
121
+ def _load_pending(agent: Solvent, session_id: str) -> dict:
122
+ sess = agent.t.get_chat_session(session_id)
123
+ if not sess or not sess.get("pending_job_json"):
124
+ return {}
125
+ try:
126
+ return json.loads(sess["pending_job_json"])
127
+ except json.JSONDecodeError:
128
+ return {}
129
+
130
+
131
+ def _save_pending(agent: Solvent, session_id: str, pending: dict) -> None:
132
+ agent.t.update_chat_session(session_id, pending_job_json=json.dumps(pending))
133
+
134
+
135
+ def _merge_commission_slots(agent: Solvent, session_id: str, text: str) -> dict:
136
+ """Accumulate topic/budget/email across turns for brief commissioning."""
137
+ pending = _load_pending(agent, session_id)
138
+ lower = text.lower()
139
+ if any(k in lower for k in ("brief", "commission", "research on", "report on")):
140
+ pending.setdefault("intent", "commission")
141
+ if "topic" in lower and ":" in text:
142
+ _, topic = text.split(":", 1)
143
+ pending["topic"] = topic.strip()[:200]
144
+ elif pending.get("intent") and len(text) > 10 and "topic" not in pending:
145
+ pending.setdefault("topic", text.strip()[:200])
146
+ m = _BUDGET_RE.search(text)
147
+ if m:
148
+ pending["budget_cents"] = int(float(m.group(1)) * 100)
149
+ em = _EMAIL_RE.search(text)
150
+ if em:
151
+ pending["customer_email"] = em.group(0)
152
+ if pending:
153
+ _save_pending(agent, session_id, pending)
154
+ return pending
155
+
156
+
157
+ def _pending_prompt(pending: dict) -> str:
158
+ if not pending or pending.get("intent") != "commission":
159
+ return ""
160
+ have = []
161
+ need = []
162
+ for key, label in (
163
+ ("topic", "topic"),
164
+ ("budget_cents", "budget_cents"),
165
+ ("customer_email", "customer_email"),
166
+ ):
167
+ if pending.get(key):
168
+ have.append(f"{label}={pending[key]}")
169
+ else:
170
+ need.append(label)
171
+ if not need:
172
+ return (
173
+ "Pending commission slots are complete: "
174
+ + ", ".join(have)
175
+ + ". Confirm with the user before submit_brief."
176
+ )
177
+ return (
178
+ "Pending commission (slot-filling): have "
179
+ + (", ".join(have) if have else "nothing yet")
180
+ + f"; still need: {', '.join(need)}."
181
+ )
182
+
183
+
184
+ def handle_message(
185
+ session_id: str,
186
+ text: str,
187
+ *,
188
+ agent: Solvent | None = None,
189
+ memory: SessionMemory | None = None,
190
+ channel: str = "cli",
191
+ ) -> str:
192
+ """Run one conversational turn; returns assistant reply text."""
193
+ ensure_workspace()
194
+ agent = agent or Solvent(seed_cents=10_000, fresh=False, sync_payment=False)
195
+ memory = memory or SessionMemory(agent.t)
196
+ system_prompt = build_chat_system_prompt(channel)
197
+
198
+ try:
199
+ text = sanitise_prompt_input(text, field_name="message", max_len=4000)
200
+ except (PromptInjectionError, InputValidationError) as exc:
201
+ return f"Message rejected: {exc}"
202
+
203
+ memory.append(session_id, "user", text)
204
+ pending = _merge_commission_slots(agent, session_id, text)
205
+ history = memory.format_for_prompt(session_id, limit=20)
206
+ live_search = os.environ.get("SOLVENT_LIVE_SEARCH", "").strip() in (
207
+ "1",
208
+ "true",
209
+ "yes",
210
+ )
211
+
212
+ catalog = {**tools.TOOL_REGISTRY, **tools.BUSINESS_TOOL_REGISTRY}
213
+ executor = _make_executor(agent, session_id, live_search)
214
+ tool_lines = "\n".join(
215
+ f"- {name}: {meta.get('description', '')}" for name, meta in catalog.items()
216
+ )
217
+
218
+ slot_hint = _pending_prompt(pending)
219
+ user = (
220
+ f"Conversation so far:\n{history}\n\n"
221
+ f"Available tools: {', '.join(sorted(catalog))}\n"
222
+ f"{tool_lines}\n"
223
+ )
224
+ if slot_hint:
225
+ user += f"\n{slot_hint}\n"
226
+ user += f"\nUser: {text}"
227
+
228
+ tool_budget = tools.MAX_TOOL_CALLS
229
+ calls_made = 0
230
+ for _ in range(6):
231
+ reply, _ = nemotron.complete(system_prompt, user)
232
+ calls = nemotron.parse_tool_calls(reply)
233
+ if not calls:
234
+ memory.append(session_id, "assistant", reply)
235
+ return reply
236
+ notes = []
237
+ for name, args in calls:
238
+ if calls_made >= tool_budget:
239
+ notes.append(
240
+ f"[budget] per-turn tool-call limit ({tool_budget}) reached; "
241
+ "answer the user now without more tool calls."
242
+ )
243
+ break
244
+ result = executor(name, args)
245
+ calls_made += 1
246
+ notes.append(f"[{name}] {result}")
247
+ user = user + f"\n\nAssistant: {reply}\n\nTool results:\n" + "\n".join(notes)
248
+
249
+ fallback = (
250
+ "I'm having trouble completing that request. "
251
+ "Try /status or ask for a quote with topic and budget."
252
+ )
253
+ memory.append(session_id, "assistant", fallback)
254
+ return fallback
255
+
256
+
257
+ def format_job_notification(event: dict) -> str | None:
258
+ stage = event.get("stage")
259
+ jid = event.get("job_id", "")
260
+ try:
261
+ from .workspace import append_daily_memory
262
+
263
+ if stage in ("paid", "fulfilled", "delivered", "declined"):
264
+ append_daily_memory(f"job {jid}: {stage}")
265
+ except Exception as exc:
266
+ logging.getLogger(__name__).warning("format_job_notification memory append failed: %s", exc)
267
+ if stage == "paid":
268
+ return (
269
+ f"Payment received for job {jid} ({fmt(event.get('amount', 0))}). Fulfillment starting."
270
+ )
271
+ if stage == "fulfilled":
272
+ return f"Job {jid} fulfilled. Report saved."
273
+ if stage == "delivered":
274
+ return f"Your brief for {jid} is ready: {event.get('url', '')}"
275
+ if stage == "declined":
276
+ return f"Job {jid} declined: {event.get('reason', '')}"
277
+ if stage == "payment_pending":
278
+ return f"Awaiting payment for {jid}: {event.get('url', '')}"
279
+ return None