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.
- solvent/__init__.py +3 -0
- solvent/__main__.py +172 -0
- solvent/agent.py +90 -0
- solvent/channels/__init__.py +1 -0
- solvent/channels/telegram.py +104 -0
- solvent/chat.py +279 -0
- solvent/cli.py +354 -0
- solvent/config.py +124 -0
- solvent/config_cmd.py +151 -0
- solvent/dashboard.py +1496 -0
- solvent/dashboard_chat.py +316 -0
- solvent/delivery.py +398 -0
- solvent/doctor.py +132 -0
- solvent/event_hub.py +49 -0
- solvent/finance.py +424 -0
- solvent/gateway.py +189 -0
- solvent/guardrails.py +195 -0
- solvent/init.py +146 -0
- solvent/job_cmd.py +258 -0
- solvent/jobs.py +60 -0
- solvent/logs.py +211 -0
- solvent/memory.py +26 -0
- solvent/nemotron.py +418 -0
- solvent/notifications.py +69 -0
- solvent/observability.py +54 -0
- solvent/onboarding.py +234 -0
- solvent/pairing.py +94 -0
- solvent/paths.py +63 -0
- solvent/pricing.py +152 -0
- solvent/py.typed +0 -0
- solvent/qr.py +62 -0
- solvent/queue.py +37 -0
- solvent/rate_limit.py +187 -0
- solvent/receipt.py +129 -0
- solvent/reconcile.py +97 -0
- solvent/security.py +427 -0
- solvent/server.py +454 -0
- solvent/service.py +119 -0
- solvent/stages.py +694 -0
- solvent/status.py +194 -0
- solvent/stripe_client.py +519 -0
- solvent/templates/workspace/AGENTS.md +34 -0
- solvent/templates/workspace/BRAIN.md +26 -0
- solvent/templates/workspace/SOUL.md +33 -0
- solvent/templates/workspace/skills/commission-research/SKILL.md +23 -0
- solvent/tools.py +195 -0
- solvent/treasury.py +900 -0
- solvent/upgrade.py +171 -0
- solvent/webhook_log.py +158 -0
- solvent/worker.py +56 -0
- solvent/workspace.py +342 -0
- solvent_agent-0.1.0.dist-info/METADATA +422 -0
- solvent_agent-0.1.0.dist-info/RECORD +57 -0
- solvent_agent-0.1.0.dist-info/WHEEL +5 -0
- solvent_agent-0.1.0.dist-info/entry_points.txt +2 -0
- solvent_agent-0.1.0.dist-info/licenses/LICENSE +21 -0
- solvent_agent-0.1.0.dist-info/top_level.txt +1 -0
solvent/__init__.py
ADDED
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
|