valun 0.3.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.
- valun/__init__.py +7 -0
- valun/__main__.py +5 -0
- valun/agent.py +468 -0
- valun/basis.py +188 -0
- valun/cli.py +361 -0
- valun/client.py +363 -0
- valun/home.py +53 -0
- valun/mcp_server.py +244 -0
- valun/measure.py +104 -0
- valun/sandbox.py +234 -0
- valun/schemas.py +149 -0
- valun-0.3.0.dist-info/METADATA +112 -0
- valun-0.3.0.dist-info/RECORD +17 -0
- valun-0.3.0.dist-info/WHEEL +5 -0
- valun-0.3.0.dist-info/entry_points.txt +2 -0
- valun-0.3.0.dist-info/licenses/LICENSE +21 -0
- valun-0.3.0.dist-info/top_level.txt +1 -0
valun/__init__.py
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Valun: let any AI agent make service-for-credit deals with other, independently controlled agents.
|
|
2
|
+
|
|
3
|
+
Framework-neutral core (client, Basis signer, delivery measurements, the nine agent tools) with adapters: a local MCP
|
|
4
|
+
server (`valun mcp`), a CLI (`valun call`), and the Hermes plugin. Experimental pilot: PC are synthetic units with no
|
|
5
|
+
monetary value; issuing a credit note creates an outstanding promise; nothing is settled.
|
|
6
|
+
"""
|
|
7
|
+
__version__ = '0.3.0'
|
valun/__main__.py
ADDED
valun/agent.py
ADDED
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
"""The Valun agent tools, independent of any agent framework.
|
|
2
|
+
|
|
3
|
+
Every adapter (the MCP server, the `valun call` CLI, the Hermes plugin) exposes the same nine tools through this
|
|
4
|
+
module. An adapter supplies a store (where the participant's key, token and non-secret state live), a few settings,
|
|
5
|
+
and a sentence saying where approvals happen; this module does the protocol work: it builds and verifies what is
|
|
6
|
+
signed, measures deliveries against the agreed terms, and explains results in plain words.
|
|
7
|
+
|
|
8
|
+
Secrets never leave the store: no tool returns, logs or echoes the signing key or the agent token.
|
|
9
|
+
Ported from the Hermes plugin's tools.py (hermes-plugin-valun 0.2.0) with the Hermes-specific parts removed.
|
|
10
|
+
"""
|
|
11
|
+
from datetime import datetime, timedelta, timezone
|
|
12
|
+
|
|
13
|
+
from . import client as vc
|
|
14
|
+
from . import measure
|
|
15
|
+
from . import sandbox
|
|
16
|
+
from . import schemas
|
|
17
|
+
|
|
18
|
+
SETTLEMENT_NOTE = ('Issuing a credit note creates an outstanding obligation (the payer owes the payee that many PC). '
|
|
19
|
+
'Nothing is settled in the Valun pilot: there is no redemption, reserve or clearing yet.')
|
|
20
|
+
NOT_SETTLED = ('A credit note is an outstanding promise to pay, not a payment: nothing is settled in the pilot. '
|
|
21
|
+
'PC are synthetic units with no monetary value.')
|
|
22
|
+
HONEST_LABELS = {
|
|
23
|
+
'what_basis_records': ('When a payer accepts a delivery, its agent signs a Basis note and the pilot Basis tracker '
|
|
24
|
+
'records it: the payer key, the payee key, the running total that payer owes that payee '
|
|
25
|
+
'(one cumulative total per key pair) and a timestamp. The tracker has no blockchain connection '
|
|
26
|
+
'(notes stay local_only) and no reserve backs the notes.'),
|
|
27
|
+
'outstanding_promise': ('An issued credit note is an outstanding promise by the payer to pay that many PC. Nothing in '
|
|
28
|
+
'the pilot settles it: there is no redemption, reserve, clearing or netting. PC are synthetic '
|
|
29
|
+
'pilot units with no monetary value.'),
|
|
30
|
+
'third_party': ('Supported: paying a third party with your OWN credit note; the third party may require that your '
|
|
31
|
+
'claims on the tracker cover it (min_payer_coverage_pct). Not supported: transferring, assigning or '
|
|
32
|
+
'endorsing a note you hold to someone else, and clearing or netting between participants.'),
|
|
33
|
+
}
|
|
34
|
+
REAL_VS_SIMULATED = {
|
|
35
|
+
'real': "signatures by each participant's own key; Valun's acceptance and approval records; credit notes held by a "
|
|
36
|
+
'real Basis tracker (read back and reconciled by Valun)',
|
|
37
|
+
'simulated': 'PC (pilot credits) have no monetary value; the Basis tracker has no blockchain connection (notes are '
|
|
38
|
+
'local_only, never on-chain); settlement does not exist in the pilot',
|
|
39
|
+
'not_proven': 'that delivered work is good (Valun records statements, it does not judge work); that any participant '
|
|
40
|
+
'will ever honour a credit note',
|
|
41
|
+
}
|
|
42
|
+
# valun_set_limits argument -> (server field, kind). Caps can only be lowered below the owner's mandate, floors raised.
|
|
43
|
+
LIMIT_FIELDS = {
|
|
44
|
+
'max_per_deal_as_payer': ('max_issue_per_deal', 'cap'), 'max_total_owed': ('max_issue_outstanding', 'cap'),
|
|
45
|
+
'max_per_deal_as_payee': ('max_accept_per_deal', 'cap'), 'max_claim_per_payer': ('max_accept_per_issuer', 'cap'),
|
|
46
|
+
'min_price_as_payee': ('min_price_as_payee', 'floor'), 'min_payer_coverage_pct': ('min_payer_coverage_pct', 'floor'),
|
|
47
|
+
}
|
|
48
|
+
TOOL_NAMES = [t['name'] for t in schemas.ALL]
|
|
49
|
+
READ_ONLY_TOOLS = ('valun_status', 'valun_directory', 'valun_inbox', 'valun_deal')
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def limits_body(args):
|
|
53
|
+
"""Map valun_set_limits arguments to the server's /limits body."""
|
|
54
|
+
body = {}
|
|
55
|
+
for arg, (field, _kind) in LIMIT_FIELDS.items():
|
|
56
|
+
if args.get(arg) is not None:
|
|
57
|
+
body[field] = int(args[arg])
|
|
58
|
+
for arg in args.get('clear') or []:
|
|
59
|
+
if arg in LIMIT_FIELDS:
|
|
60
|
+
body[LIMIT_FIELDS[arg][0]] = None
|
|
61
|
+
if args.get('goals') is not None:
|
|
62
|
+
body['notes'] = str(args['goals'])
|
|
63
|
+
return body
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def loosens(current, body):
|
|
67
|
+
"""True when a /limits body would loosen a limit the agent recorded before (a higher cap, a lower floor, or
|
|
68
|
+
removing one). Setting a limit for the first time is never looser than the owner's mandate (the server refuses that)."""
|
|
69
|
+
kinds = dict(LIMIT_FIELDS.values())
|
|
70
|
+
for field, new in body.items():
|
|
71
|
+
if field not in kinds or current.get(field) is None:
|
|
72
|
+
continue
|
|
73
|
+
old = current[field]
|
|
74
|
+
if new is None or (kinds[field] == 'cap' and new > old) or (kinds[field] == 'floor' and new < old):
|
|
75
|
+
return True
|
|
76
|
+
return False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def recently_active(last_seen, hours=24, joined_at=None):
|
|
80
|
+
"""Has this participant's agent made a call to Valun within the last `hours`? A participant that joined within that
|
|
81
|
+
window counts as active even before its agent's first call (it is new, not idle)."""
|
|
82
|
+
def within(iso):
|
|
83
|
+
try:
|
|
84
|
+
return datetime.now(timezone.utc) - datetime.fromisoformat(str(iso).replace('Z', '+00:00')) < timedelta(hours=hours)
|
|
85
|
+
except (TypeError, ValueError):
|
|
86
|
+
return False
|
|
87
|
+
return within(last_seen) or within(joined_at)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def terms_text(d):
|
|
91
|
+
"""The agreed (or current) service text: where numeric requirements such as '300-500 words' are written."""
|
|
92
|
+
t = ((d.get('current') or {}).get('terms') or {}).get('service') or {}
|
|
93
|
+
return ' '.join(str(t.get(k) or '') for k in ('title', 'description', 'deliverable'))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def error_payload(e, setup_hint='run `valun join` (or `valun enroll`) in a terminal'):
|
|
97
|
+
"""A ValunError (or any exception) as a plain dict an agent can act on. Never includes secrets."""
|
|
98
|
+
if isinstance(e, vc.ValunError):
|
|
99
|
+
out = {'error': e.code, 'message': str(e)}
|
|
100
|
+
for k in ('rule', 'mandate_version', 'measured'):
|
|
101
|
+
if k in e.extra:
|
|
102
|
+
out[k] = e.extra[k]
|
|
103
|
+
if e.code == 'mandate_exceeded':
|
|
104
|
+
out['what_this_means'] = ("Your owner's mandate does not allow this. Nothing was recorded. Do not try to work around "
|
|
105
|
+
'it; tell your owner if you think the limit should change.')
|
|
106
|
+
if e.code == 'offer_not_authorized':
|
|
107
|
+
out['what_this_means'] = ("The other side's owner no longer allows this offer (their mandate changed or is used up). "
|
|
108
|
+
'Nothing was recorded. You may decline it or wait for a new offer.')
|
|
109
|
+
if e.code in ('key_changed', 'payee_no_key'):
|
|
110
|
+
out['what_this_means'] = ("A party's agent key was revoked or replaced by its owner. Offers that name the old key can "
|
|
111
|
+
'no longer be agreed; agreed deals continue once the party has a new key.')
|
|
112
|
+
if e.code in ('unauthorized', 'not_connected', 'not_configured'):
|
|
113
|
+
out['what_this_means'] = f'This agent is not connected to Valun. Its owner must {setup_hint}.'
|
|
114
|
+
if e.code == 'owner_declined':
|
|
115
|
+
out['what_this_means'] = 'Your owner did not approve this commitment. Nothing was signed or sent.'
|
|
116
|
+
return out
|
|
117
|
+
return {'error': 'internal_error', 'message': f'{type(e).__name__}: {e}'}
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class ValunAgent:
|
|
121
|
+
"""The nine Valun tools over one participant's store.
|
|
122
|
+
|
|
123
|
+
store: a path to a private state file, or any object with .state, .secret(), .token(), .save(), .save_credentials().
|
|
124
|
+
approvals: one sentence telling the model where commitments are approved (adapter-specific).
|
|
125
|
+
check_link_hosts: look up link host names in DNS when measuring deliveries (no page is fetched).
|
|
126
|
+
"""
|
|
127
|
+
|
|
128
|
+
def __init__(self, store, approvals='', check_link_hosts=True, setup_hint=None, server=None):
|
|
129
|
+
self.store = store
|
|
130
|
+
self.approvals = approvals
|
|
131
|
+
self.check_link_hosts = check_link_hosts
|
|
132
|
+
self.setup_hint = setup_hint or 'run `valun join` (or `valun enroll`) in a terminal'
|
|
133
|
+
self.server = server
|
|
134
|
+
self.handlers = {'valun_status': self.status, 'valun_directory': self.directory, 'valun_inbox': self.inbox,
|
|
135
|
+
'valun_deal': self.deal, 'valun_propose': self.propose, 'valun_respond': self.respond,
|
|
136
|
+
'valun_deliver': self.deliver, 'valun_review_delivery': self.review, 'valun_set_limits': self.set_limits}
|
|
137
|
+
|
|
138
|
+
# ---------- plumbing ----------
|
|
139
|
+
def client(self, require_connection=True):
|
|
140
|
+
c = vc.Client(self.store, server=self.server)
|
|
141
|
+
if require_connection and (not c.connected or not c.participant_id):
|
|
142
|
+
raise vc.ValunError('not_connected', f'not enrolled: the owner must {self.setup_hint}')
|
|
143
|
+
return c
|
|
144
|
+
|
|
145
|
+
def call(self, name, args=None):
|
|
146
|
+
"""Run one tool. Returns a dict; raises ValunError (use error_payload to turn it into a result)."""
|
|
147
|
+
if name not in self.handlers:
|
|
148
|
+
raise vc.ValunError('unknown_tool', f'no Valun tool called {name!r}')
|
|
149
|
+
return self.handlers[name](args or {})
|
|
150
|
+
|
|
151
|
+
def call_safe(self, name, args=None):
|
|
152
|
+
"""Like call(), but never raises: errors come back as {'error': ...}."""
|
|
153
|
+
try:
|
|
154
|
+
return self.call(name, args)
|
|
155
|
+
except Exception as e: # noqa: BLE001 - adapters must always get a result
|
|
156
|
+
return error_payload(e, self.setup_hint)
|
|
157
|
+
|
|
158
|
+
def _resolve(self, c, who):
|
|
159
|
+
who = (who or '').strip().lower()
|
|
160
|
+
for p in c.directory():
|
|
161
|
+
if p['id'] == who or p['handle'] == who:
|
|
162
|
+
return p
|
|
163
|
+
raise vc.ValunError('unknown_participant', f'no participant called {who!r}; use valun_directory')
|
|
164
|
+
|
|
165
|
+
# ---------- approvals ----------
|
|
166
|
+
def is_commitment(self, name, args):
|
|
167
|
+
"""Does this call sign something that commits the owner (or loosen a limit)? Adapters ask the owner first."""
|
|
168
|
+
args = args or {}
|
|
169
|
+
# Fail closed: only the explicitly harmless actions skip the owner (a misspelt "Accept" must never skip them).
|
|
170
|
+
if name == 'valun_propose':
|
|
171
|
+
return True
|
|
172
|
+
if name == 'valun_respond':
|
|
173
|
+
return args.get('action') not in ('decline', 'withdraw')
|
|
174
|
+
if name == 'valun_review_delivery':
|
|
175
|
+
return args.get('decision') != 'dispute'
|
|
176
|
+
if name == 'valun_set_limits':
|
|
177
|
+
try:
|
|
178
|
+
me = self.client().me()
|
|
179
|
+
return loosens(me.get('agent_limits') or {}, limits_body(args))
|
|
180
|
+
except Exception: # noqa: BLE001 - when in doubt, ask the owner
|
|
181
|
+
return True
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
def bind(self, name, args):
|
|
185
|
+
"""Pin what the owner is about to approve: an acceptance to the current terms hash, a delivery acceptance to the
|
|
186
|
+
current deliverable hash, unless the agent already passed them. Adapters call this BEFORE asking the owner and
|
|
187
|
+
sign with the returned arguments, so the signature covers exactly what the owner saw (the server refuses a
|
|
188
|
+
stale hash instead of silently signing newer terms)."""
|
|
189
|
+
args = dict(args or {})
|
|
190
|
+
try:
|
|
191
|
+
if name == 'valun_respond' and args.get('action') == 'accept' and not args.get('expected_terms_hash'):
|
|
192
|
+
cur = (self.client().deal(args['deal_id']).get('current') or {})
|
|
193
|
+
if cur.get('terms_hash'):
|
|
194
|
+
args['expected_terms_hash'] = cur['terms_hash']
|
|
195
|
+
if name == 'valun_review_delivery' and args.get('decision') == 'accept' and not args.get('expected_deliverable_sha256'):
|
|
196
|
+
dl = self.client().deal(args['deal_id']).get('delivery') or {}
|
|
197
|
+
if dl.get('sha256'):
|
|
198
|
+
args['expected_deliverable_sha256'] = dl['sha256']
|
|
199
|
+
except Exception: # noqa: BLE001 - binding is best effort; the call itself reports real problems
|
|
200
|
+
pass
|
|
201
|
+
return args
|
|
202
|
+
|
|
203
|
+
def describe_commitment(self, name, args):
|
|
204
|
+
"""Text for an approval prompt: exactly what the agent is about to sign, and what it means."""
|
|
205
|
+
args = args or {}
|
|
206
|
+
try:
|
|
207
|
+
c = self.client()
|
|
208
|
+
if name == 'valun_set_limits':
|
|
209
|
+
me = c.me()
|
|
210
|
+
cur, mandate, body = me.get('agent_limits') or {}, me['mandate'], limits_body(args)
|
|
211
|
+
names = {field: arg for arg, (field, _k) in LIMIT_FIELDS.items()}
|
|
212
|
+
lines = ['Valun - LOOSEN limits recorded from your instructions (the Valun server enforces them):']
|
|
213
|
+
for field, new in body.items():
|
|
214
|
+
if field in names:
|
|
215
|
+
lines.append(f" {names[field]}: {cur.get(field, mandate.get(field))} -> "
|
|
216
|
+
f"{new if new is not None else 'removed'} (your owner mandate: {mandate.get(field)})")
|
|
217
|
+
lines.append('Approve only if you asked your agent for this.')
|
|
218
|
+
return '\n'.join(lines)
|
|
219
|
+
if name == 'valun_propose':
|
|
220
|
+
role, price, who = args.get('role'), args.get('price'), args.get('counterparty')
|
|
221
|
+
return '\n'.join([
|
|
222
|
+
f'Valun - sign a NEW OFFER to {who}:',
|
|
223
|
+
f" service: {args.get('title')}",
|
|
224
|
+
f" deliverable: {args.get('deliverable')}",
|
|
225
|
+
f' price: {price} PC; you are the ' + ('payer' if role == 'payer' else 'payee (you do the work)'),
|
|
226
|
+
f" deliver within {args.get('deliver_within_hours') or 48} h; the offer stays open {args.get('offer_valid_hours') or 24} h",
|
|
227
|
+
f'If {who} accepts, the deal is binding without asking you again: '
|
|
228
|
+
+ (f'after you accept their delivery you will owe them {price} PC (your credit note).' if role == 'payer'
|
|
229
|
+
else f'you must deliver; they would then owe you {price} PC (their credit note).'),
|
|
230
|
+
NOT_SETTLED])
|
|
231
|
+
d = c.deal(args.get('deal_id'))
|
|
232
|
+
cur = d.get('current') or {}
|
|
233
|
+
t, who, role = cur.get('terms') or {}, (d.get('counterparty') or {}).get('handle'), d.get('your_role')
|
|
234
|
+
price, svc = (t.get('price') or {}).get('amount'), t.get('service') or {}
|
|
235
|
+
if name == 'valun_respond' and args.get('action') == 'accept':
|
|
236
|
+
return '\n'.join([
|
|
237
|
+
f"Valun - ACCEPT {who}'s offer (deal {d['id']}, version {cur.get('version')}):",
|
|
238
|
+
f" service: {svc.get('title')}",
|
|
239
|
+
f" deliverable: {svc.get('deliverable')}",
|
|
240
|
+
f" price: {price} PC; you are the {role}; deliver by {t.get('deliver_by')}",
|
|
241
|
+
f" terms hash {(args.get('expected_terms_hash') or cur.get('terms_hash') or '')[:16]}... (rechecked by your Valun client before signing)"
|
|
242
|
+
+ ('' if not args.get('expected_terms_hash') or args['expected_terms_hash'] == cur.get('terms_hash') else
|
|
243
|
+
'\n WARNING: the offer changed after your agent read it; signing these terms will be refused'),
|
|
244
|
+
'This makes the deal binding on both sides: '
|
|
245
|
+
+ (f'after you accept the delivery you will owe {who} {price} PC.' if role == 'payer'
|
|
246
|
+
else f'you must deliver; {who} would then owe you {price} PC.'),
|
|
247
|
+
NOT_SETTLED])
|
|
248
|
+
if name == 'valun_respond' and args.get('action') == 'counter':
|
|
249
|
+
changes = [f'{k} {args[k]}' for k in ('price', 'title', 'description', 'deliverable', 'deliver_within_hours')
|
|
250
|
+
if args.get(k) not in (None, '')]
|
|
251
|
+
return '\n'.join([
|
|
252
|
+
f"Valun - sign a COUNTER-OFFER to {who} (deal {d['id']}), replacing their version {cur.get('version')} at {price} PC:",
|
|
253
|
+
' changes: ' + (', '.join(changes) or 'none'),
|
|
254
|
+
f'If {who} accepts it, the deal is binding without asking you again. You are the {role}.',
|
|
255
|
+
NOT_SETTLED])
|
|
256
|
+
if name == 'valun_review_delivery':
|
|
257
|
+
dl = d.get('delivery') or {}
|
|
258
|
+
m = measure.measure(dl.get('content') or '', terms_text(d), check_hosts=False)
|
|
259
|
+
facts = [f"{x['requirement']}: {x['measured']} ({'met' if x['met'] else 'NOT met'})" for x in m['checks_against_agreed_terms']]
|
|
260
|
+
exact = sandbox.check_delivery(t, dl.get('content') or '')
|
|
261
|
+
if exact and exact.get('checked'):
|
|
262
|
+
facts.append('exact result: ' + ('matches the agreed text' if exact.get('exact_result_matches')
|
|
263
|
+
else f"DOES NOT MATCH (differs in {', '.join(exact.get('differs_in') or [])})"))
|
|
264
|
+
return '\n'.join([
|
|
265
|
+
f"Valun - ACCEPT {who}'s delivery (deal {d['id']}) and ISSUE A CREDIT NOTE of {price} PC to {who}:",
|
|
266
|
+
f" delivered: {dl.get('title') or '(untitled)'}, {dl.get('bytes')} bytes, sha256 {(dl.get('sha256') or '')[:16]}...",
|
|
267
|
+
' measured: ' + ('; '.join(facts) or f"{m['word_count']} words, {len(m['links'])} links"),
|
|
268
|
+
f'Your agent signs a Basis note; the pilot tracker then records that you owe {who} {price} PC more.',
|
|
269
|
+
NOT_SETTLED])
|
|
270
|
+
except Exception: # noqa: BLE001 - the prompt must still appear
|
|
271
|
+
pass
|
|
272
|
+
return f'Valun: sign a commitment ({name}).'
|
|
273
|
+
|
|
274
|
+
# ---------- tools ----------
|
|
275
|
+
def status(self, a):
|
|
276
|
+
c = self.client()
|
|
277
|
+
me, info = c.me(), c.info()
|
|
278
|
+
recorded = me.get('agent_limits') or {}
|
|
279
|
+
approvals = (self.approvals or 'Commitments are signed without asking your owner first.')
|
|
280
|
+
approvals += f" The server also holds commitments priced above {me['mandate'].get('approve_above')} PC for the owner console."
|
|
281
|
+
return {'connected': True, 'server': c.state['server'], 'network': info['network'],
|
|
282
|
+
'unit': info['unit'] + ' (pilot credits, no monetary value)',
|
|
283
|
+
'you': me['participant'], 'objective_from_your_owner': me['mandate'].get('objective') or '(none set)',
|
|
284
|
+
'goals_your_owner_told_you': recorded.get('notes') or '(none recorded yet: ask your owner, then use valun_set_limits)',
|
|
285
|
+
'limits_you_recorded': {k: v for k, v in recorded.items() if k not in ('notes', 'set_at')},
|
|
286
|
+
'limits_in_force': {arg: (me.get('effective_limits') or me['mandate']).get(field) for arg, (field, _k) in LIMIT_FIELDS.items()},
|
|
287
|
+
'mandate': {k: v for k, v in me['mandate'].items() if k != 'objective'}, 'mandate_version': me['mandate_version'],
|
|
288
|
+
'approvals': approvals, 'position': me['position'], 'authority': me['authority'], 'settlement': SETTLEMENT_NOTE,
|
|
289
|
+
'honest_labels': HONEST_LABELS, 'real_vs_simulated': REAL_VS_SIMULATED}
|
|
290
|
+
|
|
291
|
+
def set_limits(self, a):
|
|
292
|
+
c = self.client()
|
|
293
|
+
body = limits_body(a)
|
|
294
|
+
if not body:
|
|
295
|
+
raise vc.ValunError('nothing_to_record', "give at least one limit, a limit to clear, or your owner's goals")
|
|
296
|
+
r = c.set_limits(body)
|
|
297
|
+
eff = r.get('effective_limits') or {}
|
|
298
|
+
return {'recorded': r.get('agent_limits'), 'limits_in_force': {arg: eff.get(field) for arg, (field, _k) in LIMIT_FIELDS.items()},
|
|
299
|
+
'what_this_means': ("The Valun server now enforces these limits at every action, together with your owner's mandate "
|
|
300
|
+
'(the stricter of the two applies). Only your owner can raise the mandate.')}
|
|
301
|
+
|
|
302
|
+
def directory(self, a):
|
|
303
|
+
c = self.client()
|
|
304
|
+
q = (a.get('query') or '').lower()
|
|
305
|
+
out = []
|
|
306
|
+
for p in c.directory():
|
|
307
|
+
if p['id'] == c.participant_id or p['status'] != 'active':
|
|
308
|
+
continue
|
|
309
|
+
text = ' '.join(str(p.get(k) or '') for k in ('handle', 'display_name', 'about', 'services')).lower()
|
|
310
|
+
if q and q not in text:
|
|
311
|
+
continue
|
|
312
|
+
pos = c.participant(p['id']).get('public_position', {})
|
|
313
|
+
out.append({'handle': p['handle'], 'id': p['id'], 'name': p['display_name'], 'operator': p['operator'], 'about': p['about'],
|
|
314
|
+
'services': p['services'], 'credit_notes_they_hold_PC': pos.get('claims_held'), 'credit_notes_they_owe_PC': pos.get('owes'),
|
|
315
|
+
'deals': pos.get('deals'), 'agent_last_seen': p.get('agent_last_seen'),
|
|
316
|
+
'agent_recently_active': recently_active(p.get('agent_last_seen'), joined_at=p.get('joined_at'))})
|
|
317
|
+
out.sort(key=lambda x: not x['agent_recently_active'])
|
|
318
|
+
return {'participants': out, 'note': ('positions are outstanding credit notes recorded by the pilot Basis tracker; nothing is settled; '
|
|
319
|
+
'PC have no value. A participant whose agent has not been seen in the last 24 hours may never answer an offer.')}
|
|
320
|
+
|
|
321
|
+
def inbox(self, a):
|
|
322
|
+
return self.client().inbox()
|
|
323
|
+
|
|
324
|
+
def deal(self, a):
|
|
325
|
+
c = self.client()
|
|
326
|
+
d = c.deal(a['deal_id'])
|
|
327
|
+
out = {k: d.get(k) for k in ('id', 'status', 'your_role', 'counterparty', 'next_actions', 'versions', 'acceptance', 'agreement',
|
|
328
|
+
'decline', 'completion', 'dispute', 'cancellation', 'approvals', 'labels')}
|
|
329
|
+
if d.get('pending_counter'):
|
|
330
|
+
pc = d['pending_counter']
|
|
331
|
+
out['your_counter_offer_awaiting_owner'] = {'version': pc['version'], 'state': pc['state'], 'terms': pc.get('terms'),
|
|
332
|
+
'terms_hash': pc['terms_hash'], 'note': 'the other side cannot see it until your owner approves it'}
|
|
333
|
+
cur, st = d.get('current') or {}, d.get('status')
|
|
334
|
+
if st == 'open' and cur:
|
|
335
|
+
out['whose_turn'] = ('yours: accept, counter or decline' if cur.get('by') != c.participant_id
|
|
336
|
+
else 'theirs: your offer is open; you cannot accept your own offer, only withdraw it or wait')
|
|
337
|
+
elif st == 'agreed':
|
|
338
|
+
out['whose_turn'] = 'yours: deliver the work' if d.get('your_role') == 'payee' else 'theirs: they deliver'
|
|
339
|
+
elif st in ('delivered', 'note_failed'):
|
|
340
|
+
out['whose_turn'] = 'yours: accept the delivery or dispute it' if d.get('your_role') == 'payer' else 'theirs: they review your delivery'
|
|
341
|
+
else:
|
|
342
|
+
out['whose_turn'] = f'nobody: the deal is {st}'
|
|
343
|
+
out['current_terms'] = d['current'] and {'version': d['current']['version'], 'by': d['current']['by'], 'state': d['current']['state'],
|
|
344
|
+
'terms': d['current']['terms'], 'terms_hash': d['current']['terms_hash']}
|
|
345
|
+
if d.get('status') == 'open' and d.get('current'):
|
|
346
|
+
try:
|
|
347
|
+
chk = c.verify_offer(d)
|
|
348
|
+
out['checked_by_your_client'] = {'terms_hash_recomputed': chk['terms_hash'], 'proposer_signature_valid': True}
|
|
349
|
+
except vc.ValunError as e:
|
|
350
|
+
out['checked_by_your_client'] = {'problem': e.code, 'message': str(e), 'advice': 'do not accept these terms'}
|
|
351
|
+
if d.get('delivery'):
|
|
352
|
+
dl = d['delivery']
|
|
353
|
+
body = dl['content'] if len(dl['content']) <= 16000 else dl['content'][:16000] + '\n[... truncated for display; full text is on the server ...]'
|
|
354
|
+
out['delivery'] = {'title': dl.get('title'), 'sha256': dl['sha256'], 'bytes': dl['bytes'], 'late': dl.get('late'), 'content': body}
|
|
355
|
+
reviewing = d.get('your_role') == 'payer' and d.get('status') in ('delivered', 'note_failed')
|
|
356
|
+
out['delivery']['measured_by_your_client'] = measure.measure(dl['content'], terms_text(d),
|
|
357
|
+
check_hosts=reviewing and self.check_link_hosts)
|
|
358
|
+
exact = sandbox.check_delivery((d.get('current') or {}).get('terms') or {}, dl['content'])
|
|
359
|
+
if exact is not None:
|
|
360
|
+
out['delivery']['checked_exactly_by_your_client'] = exact
|
|
361
|
+
if reviewing and exact.get('checked') and not exact.get('exact_result_matches'):
|
|
362
|
+
out['delivery']['advice'] = ('The delivered result does not match the exact result of the agreed text '
|
|
363
|
+
f"(differs in: {', '.join(exact.get('differs_in') or ['the result block'])}). "
|
|
364
|
+
'Dispute it with that reason instead of issuing a credit note.')
|
|
365
|
+
if d.get('credit_note'):
|
|
366
|
+
n = d['credit_note']
|
|
367
|
+
out['credit_note'] = {'state': n.get('state'), 'amount_PC': n.get('amount'), 'tracker_status': n.get('tracker_status'),
|
|
368
|
+
'tracker_cumulative_total_PC': (n.get('tracker_note') or {}).get('amount_collected'),
|
|
369
|
+
'confirmation': (n.get('tracker_note') or {}).get('confirmation'), 'verified_by_valun': n.get('verified_by_valun'),
|
|
370
|
+
'outstanding': n.get('outstanding'), 'settled': n.get('settled', False),
|
|
371
|
+
'issuer_pubkey': n.get('issuer_pubkey'), 'recipient_pubkey': n.get('recipient_pubkey')}
|
|
372
|
+
if n.get('signature_withheld'):
|
|
373
|
+
out['credit_note']['signature'] = 'withheld until the Basis tracker records the note'
|
|
374
|
+
out['history'] = d.get('history', [])[-12:]
|
|
375
|
+
prices = [(v['version'], 'you' if v['by'] == c.participant_id else (d.get('counterparty') or {}).get('handle'), v['price']['amount'])
|
|
376
|
+
for v in d.get('versions') or []]
|
|
377
|
+
if prices:
|
|
378
|
+
out['negotiation'] = {'prices_by_version': [f'v{n} {who}: {p} PC' for n, who, p in prices]}
|
|
379
|
+
mine = [p for _, who, p in prices if who == 'you'][-2:]
|
|
380
|
+
theirs = [p for _, who, p in prices if who != 'you'][-2:]
|
|
381
|
+
if len(mine) == 2 and len(theirs) == 2 and mine[0] == mine[1] and theirs[0] == theirs[1] and mine[0] != theirs[0]:
|
|
382
|
+
out['negotiation']['stalemate'] = (f'Both sides have repeated the same price ({mine[0]} vs {theirs[0]} PC). If no price '
|
|
383
|
+
'within your limits works for the other side, decline with a reason instead of countering again.')
|
|
384
|
+
return out
|
|
385
|
+
|
|
386
|
+
def propose(self, a):
|
|
387
|
+
c = self.client()
|
|
388
|
+
other = self._resolve(c, a['counterparty'])
|
|
389
|
+
# A seller of exactly checkable work publishes that it needs the text in the description; check before signing.
|
|
390
|
+
if a.get('role') == 'payer' and '``` block' in str(other.get('services') or '') \
|
|
391
|
+
and sandbox.extract_text(a.get('description') or '') is None:
|
|
392
|
+
raise vc.ValunError('missing_text', (
|
|
393
|
+
f"Not signed. {other['handle']} needs the text to process inside the description, and yours has none. Put the "
|
|
394
|
+
'exact text in a ``` fenced block in the description (or as the only "quoted" string, or after TEXT: at the '
|
|
395
|
+
'end), then make the offer again.'))
|
|
396
|
+
r = c.propose(other['id'], a['role'], a['title'], a['description'], a['deliverable'], int(a['price']),
|
|
397
|
+
int(a.get('deliver_within_hours') or 48), int(a.get('offer_valid_hours') or 24))
|
|
398
|
+
waiting = r['status'] == 'proposed_awaiting_owner'
|
|
399
|
+
idle = None if recently_active(other.get('agent_last_seen'), joined_at=other.get('joined_at')) else (
|
|
400
|
+
f"{other['handle']}'s agent has not been seen in the last 24 hours; it may never answer. Consider an active seller too.")
|
|
401
|
+
return {'deal_id': r['id'], 'status': r['status'], 'terms_hash': r['current']['terms_hash'] if r.get('current') else None,
|
|
402
|
+
'what_happened': (f"Your signed offer is waiting for your owner's approval before {other['handle']} can see it." if waiting
|
|
403
|
+
else f"{other['handle']} can now accept, decline or counter your signed offer."),
|
|
404
|
+
'if_agreed': (f"After you accept their delivery you issue them a credit note of {a['price']} PC (you will owe them; not settled)."
|
|
405
|
+
if a['role'] == 'payer' else f"After they accept your delivery they issue you a credit note of {a['price']} PC (they will owe you; not settled)."),
|
|
406
|
+
**({'warning': idle} if idle else {})}
|
|
407
|
+
|
|
408
|
+
def respond(self, a):
|
|
409
|
+
c = self.client()
|
|
410
|
+
act, did = a['action'], a['deal_id']
|
|
411
|
+
if act == 'accept':
|
|
412
|
+
r = c.accept(did, a.get('expected_terms_hash'))
|
|
413
|
+
elif act == 'decline':
|
|
414
|
+
if not (a.get('reason') or '').strip():
|
|
415
|
+
raise vc.ValunError('reason_required', 'a decline needs a short reason: the other side reads it and may make a better offer')
|
|
416
|
+
r = c.decline(did, a['reason'].strip())
|
|
417
|
+
elif act == 'withdraw':
|
|
418
|
+
r = c.withdraw(did)
|
|
419
|
+
elif act == 'counter':
|
|
420
|
+
r = c.counter(did, price=a.get('price'), title=a.get('title'), description=a.get('description'), deliverable=a.get('deliverable'),
|
|
421
|
+
deliver_by_hours=a.get('deliver_within_hours'))
|
|
422
|
+
else:
|
|
423
|
+
raise vc.ValunError('bad_action', 'action must be accept, decline, counter or withdraw')
|
|
424
|
+
held = act == 'counter' and r.get('pending_counter')
|
|
425
|
+
explain = {'agreed': 'Agreement formed: both sides have signed the same terms.',
|
|
426
|
+
'accepted_awaiting_owner': ('You signed the acceptance; it becomes binding when the owner holding it approves '
|
|
427
|
+
'(yours, or the proposer\'s if their limits require it; valun_deal shows which).'),
|
|
428
|
+
'open': ("Your signed counter-offer waits for your owner's approval; the other side cannot see it yet." if held
|
|
429
|
+
else 'Your counter-offer is open for the other side.' if act == 'counter' else 'The offer is open.'),
|
|
430
|
+
'declined': 'You declined; nothing is owed.', 'withdrawn': 'Your offer is withdrawn.'}
|
|
431
|
+
return {'deal_id': did, 'status': r['status'], 'what_happened': explain.get(r['status'], r['status']), 'next_actions': r.get('next_actions')}
|
|
432
|
+
|
|
433
|
+
def deliver(self, a):
|
|
434
|
+
c = self.client()
|
|
435
|
+
d = c.deal(a['deal_id'])
|
|
436
|
+
m = measure.measure(a['content'], terms_text(d), check_hosts=self.check_link_hosts)
|
|
437
|
+
problems = [f"{x['requirement']}: measured {x['measured']}" for x in m['checks_against_agreed_terms'] if not x['met']]
|
|
438
|
+
if m.get('warning'):
|
|
439
|
+
problems.append(m['warning'])
|
|
440
|
+
if problems and not a.get('send_anyway'):
|
|
441
|
+
raise vc.ValunError('draft_does_not_meet_terms', 'Not sent. Your draft does not meet the agreed terms as measured: '
|
|
442
|
+
+ ' | '.join(problems) + ' Fix it and deliver again (send_anyway=true only if you are sure; '
|
|
443
|
+
'the payer sees the same measurements).', extra={'measured': m})
|
|
444
|
+
r = c.deliver(a['deal_id'], a['content'], a.get('title') or '')
|
|
445
|
+
return {'deal_id': r['id'], 'status': r['status'], 'sha256': r['delivery']['sha256'],
|
|
446
|
+
'what_happened': 'Delivered and signed. The payer will accept it (issuing you a credit note) or dispute it.'}
|
|
447
|
+
|
|
448
|
+
def review(self, a):
|
|
449
|
+
if a.get('decision') not in ('accept', 'dispute'):
|
|
450
|
+
raise vc.ValunError('bad_decision', "decision must be exactly 'accept' or 'dispute'; nothing was signed")
|
|
451
|
+
c = self.client()
|
|
452
|
+
if a['decision'] == 'dispute':
|
|
453
|
+
if not (a.get('reason') or '').strip():
|
|
454
|
+
raise vc.ValunError('reason_required', 'a dispute needs a reason')
|
|
455
|
+
r = c.dispute(a['deal_id'], a['reason'])
|
|
456
|
+
return {'deal_id': r['id'], 'status': r['status'], 'what_happened': 'Disputed. No credit note was issued; the dispute is recorded.'}
|
|
457
|
+
r = c.complete(a['deal_id'], a.get('expected_deliverable_sha256'))
|
|
458
|
+
n = r.get('credit_note') or {}
|
|
459
|
+
if r['status'] == 'completion_awaiting_owner':
|
|
460
|
+
msg = 'You accepted the delivery and signed the credit note; your owner must approve before it is issued to the tracker.'
|
|
461
|
+
elif r['status'] == 'note_issued':
|
|
462
|
+
msg = (f"Credit note issued: the Basis tracker now records that you owe {r['counterparty']['handle']} "
|
|
463
|
+
f"{(n.get('tracker_note') or {}).get('amount_collected')} PC in total (this deal added {n.get('amount')}). " + SETTLEMENT_NOTE)
|
|
464
|
+
else:
|
|
465
|
+
msg = r['status']
|
|
466
|
+
return {'deal_id': r['id'], 'status': r['status'], 'what_happened': msg,
|
|
467
|
+
'credit_note': {'state': n.get('state'), 'amount_PC': n.get('amount'), 'tracker_status': n.get('tracker_status'),
|
|
468
|
+
'confirmation': (n.get('tracker_note') or {}).get('confirmation'), 'settled': False}}
|