arc402-cli 1.4.48 → 1.5.0
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.
- package/dist/commands/arena-v2.d.ts +5 -0
- package/dist/commands/arena-v2.d.ts.map +1 -0
- package/dist/commands/arena-v2.js +2265 -0
- package/dist/commands/arena-v2.js.map +1 -0
- package/dist/commands/arena.d.ts +2 -0
- package/dist/commands/arena.d.ts.map +1 -1
- package/dist/commands/arena.js +10 -28
- package/dist/commands/arena.js.map +1 -1
- package/dist/commands/chat.d.ts +3 -0
- package/dist/commands/chat.d.ts.map +1 -0
- package/dist/commands/chat.js +561 -0
- package/dist/commands/chat.js.map +1 -0
- package/dist/commands/daemon.d.ts.map +1 -1
- package/dist/commands/daemon.js +44 -5
- package/dist/commands/daemon.js.map +1 -1
- package/dist/commands/hermes-init.d.ts +16 -0
- package/dist/commands/hermes-init.d.ts.map +1 -0
- package/dist/commands/hermes-init.js +278 -0
- package/dist/commands/hermes-init.js.map +1 -0
- package/dist/commands/index.d.ts +3 -0
- package/dist/commands/index.d.ts.map +1 -0
- package/dist/commands/index.js +74 -0
- package/dist/commands/index.js.map +1 -0
- package/dist/commands/status.d.ts +18 -0
- package/dist/commands/status.d.ts.map +1 -0
- package/dist/commands/status.js +141 -0
- package/dist/commands/status.js.map +1 -0
- package/dist/commands/subscription.d.ts +3 -0
- package/dist/commands/subscription.d.ts.map +1 -0
- package/dist/commands/subscription.js +173 -0
- package/dist/commands/subscription.js.map +1 -0
- package/dist/commerce-client.d.ts +77 -0
- package/dist/commerce-client.d.ts.map +1 -0
- package/dist/commerce-client.js +224 -0
- package/dist/commerce-client.js.map +1 -0
- package/dist/commerce-index.d.ts +39 -0
- package/dist/commerce-index.d.ts.map +1 -0
- package/dist/commerce-index.js +294 -0
- package/dist/commerce-index.js.map +1 -0
- package/dist/config.d.ts +6 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js.map +1 -1
- package/dist/daemon/config.d.ts +1 -0
- package/dist/daemon/config.d.ts.map +1 -1
- package/dist/daemon/config.js +7 -3
- package/dist/daemon/config.js.map +1 -1
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +102 -5
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon-client.d.ts +46 -0
- package/dist/daemon-client.d.ts.map +1 -0
- package/dist/daemon-client.js +80 -0
- package/dist/daemon-client.js.map +1 -0
- package/dist/index.js +40 -34
- package/dist/index.js.map +1 -1
- package/dist/program.d.ts.map +1 -1
- package/dist/program.js +10 -0
- package/dist/program.js.map +1 -1
- package/hermes/DELIVERY-SPEC.md +219 -0
- package/hermes/HERMES-INTEGRATION-SPEC.md +338 -0
- package/hermes/plugins/arc402_hermes/__init__.py +5 -0
- package/hermes/plugins/arc402_hermes/plugin.py +489 -0
- package/hermes/plugins/arc402_hermes/py.typed +0 -0
- package/hermes/plugins/arc402_hermes.egg-info/PKG-INFO +24 -0
- package/hermes/plugins/arc402_hermes.egg-info/SOURCES.txt +10 -0
- package/hermes/plugins/arc402_hermes.egg-info/dependency_links.txt +1 -0
- package/hermes/plugins/arc402_hermes.egg-info/entry_points.txt +2 -0
- package/hermes/plugins/arc402_hermes.egg-info/requires.txt +5 -0
- package/hermes/plugins/arc402_hermes.egg-info/top_level.txt +1 -0
- package/hermes/plugins/arc402_plugin.py +489 -0
- package/hermes/plugins/dist/arc402_hermes-1.0.0-py3-none-any.whl +0 -0
- package/hermes/plugins/dist/arc402_hermes-1.0.0.tar.gz +0 -0
- package/hermes/plugins/pyproject.toml +47 -0
- package/hermes/skills/arc402-agent/SKILL.md +559 -0
- package/hermes/workroom/README.md +174 -0
- package/hermes/workroom/hermes-daemon.toml +21 -0
- package/hermes/workroom/hermes-worker/IDENTITY.md +44 -0
- package/hermes/workroom/hermes-worker/SOUL.md +45 -0
- package/hermes/workroom/hermes-worker/config.json +32 -0
- package/hermes/workroom/hermes-worker/datasets/.gitkeep +0 -0
- package/hermes/workroom/hermes-worker/knowledge/.gitkeep +0 -0
- package/hermes/workroom/hermes-worker/memory/learnings.md +9 -0
- package/hermes/workroom/hermes-worker/skills/.gitkeep +0 -0
- package/package.json +9 -3
- package/README.md +0 -288
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
"""
|
|
2
|
+
ARC-402 Hermes Plugin
|
|
3
|
+
=====================
|
|
4
|
+
Integrates ARC-402 at the Hermes gateway level.
|
|
5
|
+
|
|
6
|
+
Requires: Hermes >= v0.6.0 (ctx.inject_message() introduced in v0.6.0)
|
|
7
|
+
Install: Copy to ~/.hermes/plugins/ or the path configured in hermes config.yaml
|
|
8
|
+
|
|
9
|
+
Config block in hermes config.yaml:
|
|
10
|
+
plugins:
|
|
11
|
+
arc402:
|
|
12
|
+
enabled: true
|
|
13
|
+
wallet_address: "0x..."
|
|
14
|
+
machine_key_env: "ARC402_MACHINE_KEY"
|
|
15
|
+
daemon_port: 4402
|
|
16
|
+
auto_accept: true
|
|
17
|
+
spend_limits:
|
|
18
|
+
hire: 0.1
|
|
19
|
+
compute: 0.05
|
|
20
|
+
arena: 0.05
|
|
21
|
+
general: 0.001
|
|
22
|
+
workroom:
|
|
23
|
+
enabled: true
|
|
24
|
+
agent_id: "hermes-arc"
|
|
25
|
+
inference_endpoint: "http://localhost:8080/v1"
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import logging
|
|
32
|
+
import os
|
|
33
|
+
import subprocess
|
|
34
|
+
import time
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger("arc402_plugin")
|
|
38
|
+
|
|
39
|
+
# ── Hermes plugin metadata ────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
PLUGIN_NAME = "arc402"
|
|
42
|
+
PLUGIN_VERSION = "1.0.0"
|
|
43
|
+
PLUGIN_DESCRIPTION = (
|
|
44
|
+
"ARC-402 gateway integration — autonomous hire interception, spend policy "
|
|
45
|
+
"enforcement, workroom job injection, and on-chain signing via machine key."
|
|
46
|
+
)
|
|
47
|
+
REQUIRES_HERMES = ">=0.6.0"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
# ── Helpers ───────────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
def _run_arc402(args: list[str], machine_key: str | None = None, timeout: int = 30) -> tuple[int, str, str]:
|
|
53
|
+
"""
|
|
54
|
+
Run an arc402 CLI command. Returns (returncode, stdout, stderr).
|
|
55
|
+
Machine key is passed via environment, never via CLI args.
|
|
56
|
+
"""
|
|
57
|
+
env = os.environ.copy()
|
|
58
|
+
if machine_key:
|
|
59
|
+
env["ARC402_MACHINE_KEY"] = machine_key
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
result = subprocess.run(
|
|
63
|
+
["arc402"] + args,
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
timeout=timeout,
|
|
67
|
+
env=env,
|
|
68
|
+
)
|
|
69
|
+
return result.returncode, result.stdout.strip(), result.stderr.strip()
|
|
70
|
+
except subprocess.TimeoutExpired:
|
|
71
|
+
return -1, "", f"arc402 command timed out after {timeout}s: arc402 {' '.join(args)}"
|
|
72
|
+
except FileNotFoundError:
|
|
73
|
+
return -1, "", "arc402 CLI not found — install with: npm install -g arc402-cli"
|
|
74
|
+
except Exception as exc: # noqa: BLE001
|
|
75
|
+
return -1, "", f"arc402 subprocess error: {exc}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _parse_hire_proposal(message: Any) -> dict[str, Any] | None:
|
|
79
|
+
"""
|
|
80
|
+
Detect and extract an ARC-402 hire proposal from an incoming message.
|
|
81
|
+
Returns a dict with proposal fields, or None if this is not a hire proposal.
|
|
82
|
+
|
|
83
|
+
Hire proposals arrive from the ARC-402 daemon via the message stream.
|
|
84
|
+
They carry a recognisable structure: type="arc402_hire_proposal" plus
|
|
85
|
+
fields that mirror the HireProposal interface in hire-listener.ts.
|
|
86
|
+
"""
|
|
87
|
+
if not isinstance(message, dict):
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
msg_type = message.get("type") or message.get("event_type") or ""
|
|
91
|
+
if msg_type not in ("arc402_hire_proposal", "hire_proposal", "arc402.hire"):
|
|
92
|
+
return None
|
|
93
|
+
|
|
94
|
+
# Extract canonical fields — daemon may use camelCase or snake_case
|
|
95
|
+
proposal = {
|
|
96
|
+
"message_id": message.get("messageId") or message.get("message_id", ""),
|
|
97
|
+
"hirer_address": message.get("hirerAddress") or message.get("hirer_address", ""),
|
|
98
|
+
"capability": message.get("capability", ""),
|
|
99
|
+
"price_eth": str(message.get("priceEth") or message.get("price_eth") or "0"),
|
|
100
|
+
"deadline_unix": int(message.get("deadlineUnix") or message.get("deadline_unix") or 0),
|
|
101
|
+
"spec_hash": message.get("specHash") or message.get("spec_hash", ""),
|
|
102
|
+
"agreement_id": message.get("agreementId") or message.get("agreement_id", ""),
|
|
103
|
+
"task_description": message.get("taskDescription") or message.get("task_description", ""),
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
# Require at minimum a hirer address and capability
|
|
107
|
+
if not proposal["hirer_address"] or not proposal["capability"]:
|
|
108
|
+
return None
|
|
109
|
+
|
|
110
|
+
return proposal
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _parse_job_completed(message: Any) -> dict[str, Any] | None:
|
|
114
|
+
"""
|
|
115
|
+
Detect a job-completed event from the ARC-402 daemon.
|
|
116
|
+
Returns fields dict or None.
|
|
117
|
+
"""
|
|
118
|
+
if not isinstance(message, dict):
|
|
119
|
+
return None
|
|
120
|
+
|
|
121
|
+
msg_type = message.get("type") or message.get("event_type") or ""
|
|
122
|
+
if msg_type not in ("arc402_job_completed", "job_completed", "arc402.job.completed"):
|
|
123
|
+
return None
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
"agreement_id": message.get("agreementId") or message.get("agreement_id", ""),
|
|
127
|
+
"root_hash": message.get("rootHash") or message.get("root_hash", ""),
|
|
128
|
+
"capability": message.get("capability", ""),
|
|
129
|
+
"earnings_eth": str(message.get("earningsEth") or message.get("earnings_eth") or "0"),
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _within_spend_limit(price_eth: str, limit_eth: float) -> bool:
|
|
134
|
+
"""Return True if price_eth (string) is within limit_eth."""
|
|
135
|
+
try:
|
|
136
|
+
return float(price_eth) <= limit_eth
|
|
137
|
+
except (ValueError, TypeError):
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _get_active_job_task(daemon_port: int) -> str | None:
|
|
142
|
+
"""
|
|
143
|
+
Query the daemon's IPC endpoint for the currently active workroom job.
|
|
144
|
+
Returns task.md contents as a string, or None if no active job.
|
|
145
|
+
"""
|
|
146
|
+
import http.client # noqa: PLC0415
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
conn = http.client.HTTPConnection("127.0.0.1", daemon_port, timeout=5)
|
|
150
|
+
conn.request("GET", "/worker/active-job")
|
|
151
|
+
resp = conn.getresponse()
|
|
152
|
+
if resp.status != 200:
|
|
153
|
+
return None
|
|
154
|
+
body = json.loads(resp.read().decode("utf-8"))
|
|
155
|
+
return body.get("task_description") or body.get("task_md")
|
|
156
|
+
except Exception: # noqa: BLE001
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ── Plugin class ──────────────────────────────────────────────────────────────
|
|
161
|
+
|
|
162
|
+
class ARC402Plugin:
|
|
163
|
+
"""
|
|
164
|
+
Hermes v0.6.0 plugin for ARC-402 protocol integration.
|
|
165
|
+
|
|
166
|
+
Hooks:
|
|
167
|
+
on_startup(ctx) — verify daemon, wallet, and machine key on gateway start
|
|
168
|
+
on_message(ctx, message) — intercept hire proposals; auto-accept or hold for user
|
|
169
|
+
on_session_start(ctx) — inject active job context into agent system prompt
|
|
170
|
+
ctx.inject_message() — push notifications into conversation stream
|
|
171
|
+
|
|
172
|
+
All subprocess calls go through arc402 CLI to avoid duplicating on-chain signing logic.
|
|
173
|
+
The machine key is read from the environment variable named in config (machine_key_env)
|
|
174
|
+
and is NEVER passed to worker processes, logged, or included in injected messages.
|
|
175
|
+
"""
|
|
176
|
+
|
|
177
|
+
def __init__(self, config: dict[str, Any]) -> None:
|
|
178
|
+
self.config = config
|
|
179
|
+
self.wallet_address: str = config.get("wallet_address", "")
|
|
180
|
+
self.machine_key_env: str = config.get("machine_key_env", "ARC402_MACHINE_KEY")
|
|
181
|
+
self.daemon_port: int = int(config.get("daemon_port", 4402))
|
|
182
|
+
self.auto_accept: bool = bool(config.get("auto_accept", True))
|
|
183
|
+
self.spend_limits: dict[str, float] = {
|
|
184
|
+
"hire": float(config.get("spend_limits", {}).get("hire", 0.1)),
|
|
185
|
+
"compute": float(config.get("spend_limits", {}).get("compute", 0.05)),
|
|
186
|
+
"arena": float(config.get("spend_limits", {}).get("arena", 0.05)),
|
|
187
|
+
"general": float(config.get("spend_limits", {}).get("general", 0.001)),
|
|
188
|
+
}
|
|
189
|
+
workroom_cfg = config.get("workroom", {})
|
|
190
|
+
self.workroom_enabled: bool = bool(workroom_cfg.get("enabled", True))
|
|
191
|
+
self.workroom_agent_id: str = workroom_cfg.get("agent_id", "hermes-arc")
|
|
192
|
+
self.workroom_inference_endpoint: str = workroom_cfg.get(
|
|
193
|
+
"inference_endpoint", "http://localhost:8080/v1"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
self._machine_key: str | None = None # loaded lazily in on_startup
|
|
197
|
+
|
|
198
|
+
# ── Hook: on_startup ──────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
async def on_startup(self, ctx: Any) -> None:
|
|
201
|
+
"""
|
|
202
|
+
Called when the Hermes gateway starts.
|
|
203
|
+
|
|
204
|
+
1. Loads the machine key from the environment.
|
|
205
|
+
2. Checks if the ARC-402 daemon is running; starts it if not.
|
|
206
|
+
3. Verifies the wallet is funded and the machine key is authorised.
|
|
207
|
+
4. Logs startup status — does not raise on non-fatal issues (daemon unreachable
|
|
208
|
+
is recoverable; missing machine key is not).
|
|
209
|
+
"""
|
|
210
|
+
logger.info("ARC-402 plugin starting up (wallet=%s, daemon_port=%d)",
|
|
211
|
+
self.wallet_address or "(not set)", self.daemon_port)
|
|
212
|
+
|
|
213
|
+
# 1. Load machine key
|
|
214
|
+
machine_key = os.environ.get(self.machine_key_env, "").strip()
|
|
215
|
+
if not machine_key:
|
|
216
|
+
logger.error(
|
|
217
|
+
"ARC-402 plugin: machine key not found in env var %s. "
|
|
218
|
+
"Auto-accept will be disabled.",
|
|
219
|
+
self.machine_key_env,
|
|
220
|
+
)
|
|
221
|
+
self.auto_accept = False
|
|
222
|
+
else:
|
|
223
|
+
self._machine_key = machine_key
|
|
224
|
+
logger.info("ARC-402 plugin: machine key loaded from %s", self.machine_key_env)
|
|
225
|
+
|
|
226
|
+
# 2. Check daemon
|
|
227
|
+
rc, stdout, stderr = _run_arc402(["daemon", "status", "--json"], timeout=10)
|
|
228
|
+
if rc != 0:
|
|
229
|
+
logger.warning("ARC-402 daemon not responding (rc=%d, err=%s). Attempting start...", rc, stderr)
|
|
230
|
+
start_rc, _, start_err = _run_arc402(["daemon", "start"], timeout=30)
|
|
231
|
+
if start_rc != 0:
|
|
232
|
+
logger.error("ARC-402 daemon failed to start: %s", start_err)
|
|
233
|
+
else:
|
|
234
|
+
# Wait briefly for daemon to initialise
|
|
235
|
+
time.sleep(2)
|
|
236
|
+
logger.info("ARC-402 daemon started.")
|
|
237
|
+
else:
|
|
238
|
+
try:
|
|
239
|
+
status = json.loads(stdout)
|
|
240
|
+
logger.info("ARC-402 daemon healthy: %s", status.get("status", "ok"))
|
|
241
|
+
except (json.JSONDecodeError, TypeError):
|
|
242
|
+
logger.info("ARC-402 daemon healthy (non-JSON status response).")
|
|
243
|
+
|
|
244
|
+
# 3. Verify wallet
|
|
245
|
+
if self.wallet_address:
|
|
246
|
+
rc, stdout, _ = _run_arc402(["wallet", "status", "--json"], timeout=10)
|
|
247
|
+
if rc == 0:
|
|
248
|
+
try:
|
|
249
|
+
wallet_status = json.loads(stdout)
|
|
250
|
+
balance = wallet_status.get("balance_eth", "unknown")
|
|
251
|
+
trust = wallet_status.get("trust_score", "unknown")
|
|
252
|
+
logger.info("ARC-402 wallet: balance=%s ETH, trust=%s", balance, trust)
|
|
253
|
+
except (json.JSONDecodeError, TypeError):
|
|
254
|
+
logger.info("ARC-402 wallet status: %s", stdout[:200])
|
|
255
|
+
else:
|
|
256
|
+
logger.warning("ARC-402 wallet status check failed — wallet may not be deployed.")
|
|
257
|
+
else:
|
|
258
|
+
logger.warning("ARC-402 plugin: wallet_address not configured. Some features disabled.")
|
|
259
|
+
|
|
260
|
+
# 4. Workroom check
|
|
261
|
+
if self.workroom_enabled:
|
|
262
|
+
rc, _, _ = _run_arc402(["workroom", "status", "--json"], timeout=10)
|
|
263
|
+
if rc != 0:
|
|
264
|
+
logger.warning(
|
|
265
|
+
"ARC-402 workroom not running. Start with: arc402 workroom start"
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
logger.info("ARC-402 workroom: healthy")
|
|
269
|
+
|
|
270
|
+
logger.info(
|
|
271
|
+
"ARC-402 plugin ready. auto_accept=%s, spend_limits=%s",
|
|
272
|
+
self.auto_accept,
|
|
273
|
+
self.spend_limits,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
# ── Hook: on_message ──────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
async def on_message(self, ctx: Any, message: Any) -> Any:
|
|
279
|
+
"""
|
|
280
|
+
Called for every incoming message before it reaches the agent.
|
|
281
|
+
|
|
282
|
+
If the message is a hire proposal:
|
|
283
|
+
- Validates against spend policy
|
|
284
|
+
- Within limits + auto_accept enabled → machine key signs accept → inject notification
|
|
285
|
+
- Outside limits → inject hold notification for user review
|
|
286
|
+
- Not a proposal → pass through unchanged
|
|
287
|
+
|
|
288
|
+
If the message is a job-completed event:
|
|
289
|
+
- Inject completion summary into conversation
|
|
290
|
+
|
|
291
|
+
All other messages are returned unchanged.
|
|
292
|
+
"""
|
|
293
|
+
# Check for hire proposal
|
|
294
|
+
proposal = _parse_hire_proposal(message)
|
|
295
|
+
if proposal is not None:
|
|
296
|
+
return await self._handle_hire_proposal(ctx, proposal)
|
|
297
|
+
|
|
298
|
+
# Check for job completed
|
|
299
|
+
completion = _parse_job_completed(message)
|
|
300
|
+
if completion is not None:
|
|
301
|
+
await self._handle_job_completed(ctx, completion)
|
|
302
|
+
# Return None so the raw daemon event doesn't flood the agent context
|
|
303
|
+
return None
|
|
304
|
+
|
|
305
|
+
# Pass everything else through
|
|
306
|
+
return message
|
|
307
|
+
|
|
308
|
+
async def _handle_hire_proposal(self, ctx: Any, proposal: dict[str, Any]) -> None:
|
|
309
|
+
"""
|
|
310
|
+
Process an incoming hire proposal.
|
|
311
|
+
Returns None — the proposal is consumed and replaced by inject_message notifications.
|
|
312
|
+
"""
|
|
313
|
+
capability = proposal["capability"]
|
|
314
|
+
price_eth = proposal["price_eth"]
|
|
315
|
+
hirer = proposal["hirer_address"]
|
|
316
|
+
agreement_id = proposal.get("agreement_id", "")
|
|
317
|
+
task_preview = (proposal.get("task_description") or "")[:200]
|
|
318
|
+
|
|
319
|
+
logger.info(
|
|
320
|
+
"ARC-402 hire proposal: capability=%s, price=%s ETH, hirer=%s, agreement=%s",
|
|
321
|
+
capability, price_eth, hirer, agreement_id,
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# Determine relevant spend limit category
|
|
325
|
+
limit = self._resolve_spend_limit(capability)
|
|
326
|
+
|
|
327
|
+
if self.auto_accept and self._machine_key and _within_spend_limit(price_eth, limit):
|
|
328
|
+
# Auto-accept path
|
|
329
|
+
logger.info(
|
|
330
|
+
"Auto-accepting: %.6f ETH within limit %.6f ETH for category",
|
|
331
|
+
float(price_eth), limit,
|
|
332
|
+
)
|
|
333
|
+
accepted = await self._accept_hire(proposal)
|
|
334
|
+
if accepted:
|
|
335
|
+
notification = (
|
|
336
|
+
f"**ARC-402: Job accepted**\n\n"
|
|
337
|
+
f"- Capability: `{capability}`\n"
|
|
338
|
+
f"- Price: {price_eth} ETH\n"
|
|
339
|
+
f"- Hirer: `{hirer}`\n"
|
|
340
|
+
+ (f"- Agreement: `{agreement_id}`\n" if agreement_id else "")
|
|
341
|
+
+ (f"- Task: {task_preview}\n" if task_preview else "")
|
|
342
|
+
+ f"\nJob queued in workroom. Worker will pick it up on next execution cycle."
|
|
343
|
+
)
|
|
344
|
+
await ctx.inject_message(notification, role="system")
|
|
345
|
+
else:
|
|
346
|
+
notification = (
|
|
347
|
+
f"**ARC-402: Auto-accept failed**\n\n"
|
|
348
|
+
f"Hire proposal from `{hirer}` (capability: `{capability}`, "
|
|
349
|
+
f"price: {price_eth} ETH) could not be auto-accepted. "
|
|
350
|
+
f"Check daemon logs: `arc402 daemon logs`"
|
|
351
|
+
)
|
|
352
|
+
await ctx.inject_message(notification, role="system")
|
|
353
|
+
else:
|
|
354
|
+
# Hold for user review
|
|
355
|
+
if not self.auto_accept:
|
|
356
|
+
reason = "auto_accept is disabled"
|
|
357
|
+
elif not self._machine_key:
|
|
358
|
+
reason = "machine key not configured"
|
|
359
|
+
else:
|
|
360
|
+
reason = f"price {price_eth} ETH exceeds limit {limit:.4f} ETH for this capability"
|
|
361
|
+
|
|
362
|
+
logger.info("Holding hire proposal for user review: %s", reason)
|
|
363
|
+
|
|
364
|
+
notification = (
|
|
365
|
+
f"**ARC-402: Hire proposal requires approval**\n\n"
|
|
366
|
+
f"- Capability: `{capability}`\n"
|
|
367
|
+
f"- Price: {price_eth} ETH\n"
|
|
368
|
+
f"- Hirer: `{hirer}`\n"
|
|
369
|
+
+ (f"- Agreement: `{agreement_id}`\n" if agreement_id else "")
|
|
370
|
+
+ (f"- Task: {task_preview}\n" if task_preview else "")
|
|
371
|
+
+ f"\n**Reason held:** {reason}\n\n"
|
|
372
|
+
f"To accept manually: `arc402 hire accept {agreement_id or proposal['message_id']}`\n"
|
|
373
|
+
f"To reject: `arc402 hire reject {agreement_id or proposal['message_id']}`"
|
|
374
|
+
)
|
|
375
|
+
await ctx.inject_message(notification, role="system")
|
|
376
|
+
|
|
377
|
+
return None
|
|
378
|
+
|
|
379
|
+
async def _accept_hire(self, proposal: dict[str, Any]) -> bool:
|
|
380
|
+
"""
|
|
381
|
+
Sign and submit hire acceptance via machine key. Returns True on success.
|
|
382
|
+
"""
|
|
383
|
+
agreement_id = proposal.get("agreement_id", "")
|
|
384
|
+
message_id = proposal.get("message_id", "")
|
|
385
|
+
target_id = agreement_id or message_id
|
|
386
|
+
|
|
387
|
+
if not target_id:
|
|
388
|
+
logger.error("ARC-402: Cannot accept hire — no agreement_id or message_id in proposal")
|
|
389
|
+
return False
|
|
390
|
+
|
|
391
|
+
cmd = ["hire", "accept", target_id, "--machine-key-env", self.machine_key_env]
|
|
392
|
+
rc, stdout, stderr = _run_arc402(cmd, machine_key=self._machine_key, timeout=60)
|
|
393
|
+
|
|
394
|
+
if rc == 0:
|
|
395
|
+
logger.info("ARC-402: Hire accepted successfully: %s", target_id)
|
|
396
|
+
return True
|
|
397
|
+
else:
|
|
398
|
+
logger.error(
|
|
399
|
+
"ARC-402: Hire accept failed (rc=%d): %s — %s",
|
|
400
|
+
rc, stdout[:200], stderr[:200],
|
|
401
|
+
)
|
|
402
|
+
return False
|
|
403
|
+
|
|
404
|
+
async def _handle_job_completed(self, ctx: Any, completion: dict[str, Any]) -> None:
|
|
405
|
+
"""Inject a job-completion summary into the conversation."""
|
|
406
|
+
agreement_id = completion.get("agreement_id", "")
|
|
407
|
+
root_hash = completion.get("root_hash", "")
|
|
408
|
+
capability = completion.get("capability", "")
|
|
409
|
+
earnings = completion.get("earnings_eth", "0")
|
|
410
|
+
|
|
411
|
+
logger.info(
|
|
412
|
+
"ARC-402 job completed: agreement=%s, root_hash=%s, earnings=%s ETH",
|
|
413
|
+
agreement_id, root_hash, earnings,
|
|
414
|
+
)
|
|
415
|
+
|
|
416
|
+
notification = (
|
|
417
|
+
f"**ARC-402: Job completed**\n\n"
|
|
418
|
+
f"- Agreement: `{agreement_id}`\n"
|
|
419
|
+
f"- Capability: `{capability}`\n"
|
|
420
|
+
f"- Root hash: `{root_hash}`\n"
|
|
421
|
+
f"- Earnings: {earnings} ETH\n\n"
|
|
422
|
+
f"Deliverable committed on-chain. Escrow release pending client acceptance."
|
|
423
|
+
)
|
|
424
|
+
await ctx.inject_message(notification, role="system")
|
|
425
|
+
|
|
426
|
+
def _resolve_spend_limit(self, capability: str) -> float:
|
|
427
|
+
"""
|
|
428
|
+
Map a capability string to the appropriate spend limit.
|
|
429
|
+
Hire limit applies to any capability not otherwise matched.
|
|
430
|
+
"""
|
|
431
|
+
cap_lower = capability.lower()
|
|
432
|
+
if "compute" in cap_lower:
|
|
433
|
+
return self.spend_limits["compute"]
|
|
434
|
+
if "arena" in cap_lower or "challenge" in cap_lower:
|
|
435
|
+
return self.spend_limits["arena"]
|
|
436
|
+
# Default to hire limit for all service-type capabilities
|
|
437
|
+
return self.spend_limits["hire"]
|
|
438
|
+
|
|
439
|
+
# ── Hook: on_session_start ────────────────────────────────────────────────
|
|
440
|
+
|
|
441
|
+
async def on_session_start(self, ctx: Any) -> None:
|
|
442
|
+
"""
|
|
443
|
+
Called when a new conversation session starts in the Hermes gateway.
|
|
444
|
+
|
|
445
|
+
If there is an active workroom job, injects the task context (task.md contents)
|
|
446
|
+
into the agent's system prompt so the agent is immediately aware of its current
|
|
447
|
+
hired work without needing to poll the daemon.
|
|
448
|
+
"""
|
|
449
|
+
if not self.workroom_enabled:
|
|
450
|
+
return
|
|
451
|
+
|
|
452
|
+
task_content = _get_active_job_task(self.daemon_port)
|
|
453
|
+
if not task_content:
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
logger.info("ARC-402: Active workroom job found — injecting task context into session")
|
|
457
|
+
|
|
458
|
+
context_message = (
|
|
459
|
+
"**ARC-402: Active workroom job**\n\n"
|
|
460
|
+
"You have a hired task currently running in the workroom. "
|
|
461
|
+
"If you are the worker agent processing this task, refer to the task details below.\n\n"
|
|
462
|
+
"---\n\n"
|
|
463
|
+
f"{task_content}\n\n"
|
|
464
|
+
"---\n\n"
|
|
465
|
+
"Emit your deliverable using the `<arc402_delivery>` block format when complete."
|
|
466
|
+
)
|
|
467
|
+
|
|
468
|
+
await ctx.inject_message(context_message, role="system")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ── Hermes plugin registration ────────────────────────────────────────────────
|
|
472
|
+
|
|
473
|
+
def create_plugin(config: dict[str, Any]) -> ARC402Plugin:
|
|
474
|
+
"""
|
|
475
|
+
Hermes plugin entry point. Called by the Hermes plugin loader.
|
|
476
|
+
`config` is the dict from the `plugins.arc402` section of hermes config.yaml.
|
|
477
|
+
"""
|
|
478
|
+
return ARC402Plugin(config)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# Hermes plugin metadata — read by the plugin loader
|
|
482
|
+
plugin_info = {
|
|
483
|
+
"name": PLUGIN_NAME,
|
|
484
|
+
"version": PLUGIN_VERSION,
|
|
485
|
+
"description": PLUGIN_DESCRIPTION,
|
|
486
|
+
"requires_hermes": REQUIRES_HERMES,
|
|
487
|
+
"hooks": ["on_startup", "on_message", "on_session_start"],
|
|
488
|
+
"entry_point": "create_plugin",
|
|
489
|
+
}
|
|
File without changes
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arc402-hermes
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: ARC-402 plugin for the Hermes gateway — autonomous hire interception, spend policy enforcement, workroom job injection, and on-chain signing.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://github.com/arc402/arc402
|
|
7
|
+
Project-URL: Documentation, https://arc402.xyz/docs/hermes-integration
|
|
8
|
+
Project-URL: Repository, https://github.com/arc402/arc402
|
|
9
|
+
Project-URL: Bug Tracker, https://github.com/arc402/arc402/issues
|
|
10
|
+
Keywords: arc402,hermes,web3,agent-economy,plugin
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
Requires-Dist: requests>=2.31
|
|
22
|
+
Provides-Extra: dev
|
|
23
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
24
|
+
Requires-Dist: pytest-asyncio>=0.21; extra == "dev"
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
pyproject.toml
|
|
2
|
+
arc402_hermes/__init__.py
|
|
3
|
+
arc402_hermes/plugin.py
|
|
4
|
+
arc402_hermes/py.typed
|
|
5
|
+
arc402_hermes.egg-info/PKG-INFO
|
|
6
|
+
arc402_hermes.egg-info/SOURCES.txt
|
|
7
|
+
arc402_hermes.egg-info/dependency_links.txt
|
|
8
|
+
arc402_hermes.egg-info/entry_points.txt
|
|
9
|
+
arc402_hermes.egg-info/requires.txt
|
|
10
|
+
arc402_hermes.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
arc402_hermes
|