agentmint-hermes-runner 0.4.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.
@@ -0,0 +1,40 @@
1
+ from .auth.bearer import BearerAuth
2
+ from .auth.tempo import TempoAuth
3
+ from .dispatcher import AgentMintDispatcher
4
+ from .exceptions import (
5
+ AgentMintError,
6
+ DispatchInterrupted,
7
+ DispatchTimeout,
8
+ UnsupportedToolset,
9
+ )
10
+ from .hermes_patch import install_delegate_task_wrapper
11
+ from .models import AgentRecord, DispatchResult, Task
12
+ from .translation import (
13
+ DEFAULT_TOOLSETS,
14
+ ROLE_HINTS,
15
+ TOOLSET_RESTRICTION_HINTS,
16
+ UNSUPPORTED_TOOLSETS,
17
+ compose_prompt,
18
+ )
19
+
20
+ __version__ = "0.4.0"
21
+
22
+ __all__ = [
23
+ "AgentMintDispatcher",
24
+ "BearerAuth",
25
+ "TempoAuth",
26
+ "AgentRecord",
27
+ "DispatchResult",
28
+ "Task",
29
+ "AgentMintError",
30
+ "DispatchTimeout",
31
+ "DispatchInterrupted",
32
+ "UnsupportedToolset",
33
+ "compose_prompt",
34
+ "DEFAULT_TOOLSETS",
35
+ "ROLE_HINTS",
36
+ "TOOLSET_RESTRICTION_HINTS",
37
+ "UNSUPPORTED_TOOLSETS",
38
+ "install_delegate_task_wrapper",
39
+ "__version__",
40
+ ]
@@ -0,0 +1,5 @@
1
+ from .base import Auth
2
+ from .bearer import BearerAuth
3
+ from .tempo import TempoAuth
4
+
5
+ __all__ = ["Auth", "BearerAuth", "TempoAuth"]
@@ -0,0 +1,18 @@
1
+ from typing import Protocol
2
+
3
+
4
+ class Auth(Protocol):
5
+ """Transport strategy for AgentMint /a2a calls.
6
+
7
+ Implementations own the full HTTP exchange so we can support both
8
+ header-based auth (Bearer JWT) and external-CLI auth (Tempo's
9
+ `tempo request` subprocess that handles the 402 dance end-to-end).
10
+ """
11
+
12
+ def call(self, endpoint: str, method: str, body: bytes) -> bytes:
13
+ """POST `body` to `endpoint` and return the response body bytes.
14
+
15
+ `method` is the JSON-RPC method name (for logging only — the actual
16
+ envelope is already serialized into `body`).
17
+ """
18
+ ...
@@ -0,0 +1,29 @@
1
+ import httpx
2
+
3
+
4
+ class BearerAuth:
5
+ """Stripe-Link credit-wallet authentication via cached JWT.
6
+
7
+ Bootstrap the JWT once via `link-cli` calling `credits.topup`; every
8
+ subsequent /a2a call uses `Authorization: Bearer <jwt>` and debits the
9
+ caller-wide credit wallet keyed by `link_stripe:cus_...`.
10
+ """
11
+
12
+ def __init__(self, jwt: str, timeout: float = 30.0):
13
+ if not jwt:
14
+ raise ValueError("jwt is required")
15
+ self.jwt = jwt
16
+ self.timeout = timeout
17
+
18
+ def call(self, endpoint: str, method: str, body: bytes) -> bytes:
19
+ with httpx.Client(timeout=self.timeout) as http:
20
+ resp = http.post(
21
+ endpoint,
22
+ content=body,
23
+ headers={
24
+ "Content-Type": "application/json",
25
+ "Authorization": f"Bearer {self.jwt}",
26
+ },
27
+ )
28
+ resp.raise_for_status()
29
+ return resp.content
@@ -0,0 +1,37 @@
1
+ import subprocess
2
+
3
+
4
+ class TempoAuth:
5
+ """Per-call MPP payment via the `tempo request` CLI.
6
+
7
+ The Tempo CLI handles the 402 challenge / USDC.e transfer / signature
8
+ end-to-end as a single subprocess, then prints the merchant response on
9
+ stdout. We just shell out and return the stdout bytes.
10
+
11
+ Note: `tempo-request` plugin must be at version 0.5.2. Newer versions
12
+ (0.6.0+) hit "Invalid base64 JSON header" against AgentMint's challenge.
13
+ Downgrade via: `tempo cli 0.0.0 downgrade tempo request cli to 0.5.2`
14
+ """
15
+
16
+ def __init__(
17
+ self,
18
+ executable: str = "tempo",
19
+ account: str | None = None,
20
+ timeout: float = 120.0,
21
+ ):
22
+ self.executable = executable
23
+ self.account = account
24
+ self.timeout = timeout
25
+
26
+ def call(self, endpoint: str, method: str, body: bytes) -> bytes:
27
+ cmd: list[str] = [self.executable, "request", "-X", "POST", "--json", body.decode("utf-8")]
28
+ if self.account:
29
+ cmd.extend(["-n", self.account])
30
+ cmd.append(endpoint)
31
+ proc = subprocess.run(
32
+ cmd,
33
+ capture_output=True,
34
+ timeout=self.timeout,
35
+ check=True,
36
+ )
37
+ return proc.stdout
@@ -0,0 +1,40 @@
1
+ import json
2
+ import uuid
3
+ from typing import Any
4
+
5
+ from .auth.base import Auth
6
+ from .exceptions import AgentMintError
7
+
8
+
9
+ class Client:
10
+ """Minimal JSON-RPC 2.0 client for AgentMint /a2a.
11
+
12
+ Auth strategy owns the HTTP transport; this class only handles envelope
13
+ serialization and error mapping.
14
+ """
15
+
16
+ def __init__(self, endpoint: str, auth: Auth):
17
+ self.endpoint = endpoint
18
+ self.auth = auth
19
+
20
+ def call(self, method: str, params: dict[str, Any] | None = None) -> Any:
21
+ body = json.dumps(
22
+ {
23
+ "jsonrpc": "2.0",
24
+ "id": str(uuid.uuid4()),
25
+ "method": method,
26
+ "params": params or {},
27
+ }
28
+ ).encode("utf-8")
29
+ resp_bytes = self.auth.call(self.endpoint, method, body)
30
+ try:
31
+ data = json.loads(resp_bytes)
32
+ except json.JSONDecodeError as e:
33
+ raise AgentMintError(f"non-JSON response from {self.endpoint}: {e}") from e
34
+ if "error" in data:
35
+ err = data["error"]
36
+ raise AgentMintError(
37
+ f"{err.get('code', 'unknown')}: {err.get('message', '')}",
38
+ data=err.get("data"),
39
+ )
40
+ return data.get("result")
@@ -0,0 +1,226 @@
1
+ from __future__ import annotations
2
+
3
+ import threading
4
+ from concurrent.futures import CancelledError, ThreadPoolExecutor
5
+ from concurrent.futures import TimeoutError as FutureTimeoutError
6
+ from typing import Any
7
+
8
+ from .auth.base import Auth
9
+ from .client import Client
10
+ from .exceptions import DispatchInterrupted, DispatchTimeout
11
+ from .models import AgentRecord, DispatchResult, Task
12
+ from .translation import compose_prompt
13
+
14
+ CHILD_TIMEOUT_FLOOR = 30.0 # Hermes parity: floor 30s when timeout is enabled
15
+
16
+
17
+ class AgentMintDispatcher:
18
+ """High-level wrapper around AgentMint's /a2a JSON-RPC surface.
19
+
20
+ Translates Hermes' `delegate_task` ergonomics (goal + context + toolsets
21
+ + role) into AgentMint's single-`prompt` agent.run model. The
22
+ persistent-sandbox semantics are NOT abstracted — that's the value: a
23
+ Hermes operator calls `dispatch()` and the work lands in a sandbox
24
+ whose /workspace survives across calls.
25
+ """
26
+
27
+ def __init__(
28
+ self,
29
+ auth: Auth,
30
+ endpoint: str = "https://api.agentmint.store/a2a",
31
+ ):
32
+ self.client = Client(endpoint, auth)
33
+
34
+ # ----- direct method passthroughs ------------------------------------
35
+
36
+ def create(self, name: str, **kwargs: Any) -> AgentRecord:
37
+ params = {"name": name, **kwargs}
38
+ result = self.client.call("agent.create", params)
39
+ return AgentRecord.from_dict(result or {})
40
+
41
+ def get(self, agent_name: str) -> AgentRecord:
42
+ result = self.client.call("agent.get", {"name": agent_name})
43
+ return AgentRecord.from_dict(result or {})
44
+
45
+ def list(self, limit: int = 50, offset: int = 0) -> list[AgentRecord]:
46
+ result = self.client.call("agent.list", {"limit": limit, "offset": offset})
47
+ agents = (result or {}).get("agents", [])
48
+ return [AgentRecord.from_dict(a) for a in agents]
49
+
50
+ def delete(self, agent_name: str) -> None:
51
+ self.client.call("agent.delete", {"name": agent_name})
52
+
53
+ def cancel(self, agent_name: str) -> None:
54
+ self.client.call("agent.cancel", {"name": agent_name})
55
+
56
+ def run_status(self, run_id: str) -> dict[str, Any]:
57
+ """Read the status of an async dispatch by `run_id` (returned from
58
+ `dispatch(async_=True)`).
59
+
60
+ Bearer-only / Stripe-Link-only on the server side (v0.7.0+). Free.
61
+ Used by the polling delivery mode in `hermes_patch`.
62
+ """
63
+ return self.client.call("agent.run.status", {"run_id": run_id})
64
+
65
+ # ----- single dispatch -----------------------------------------------
66
+
67
+ def dispatch(
68
+ self,
69
+ agent_name: str,
70
+ goal: str,
71
+ context: str | None = None,
72
+ toolsets: list[str] | None = None,
73
+ role: str = "leaf",
74
+ max_iterations: int | None = None,
75
+ files: list[dict] | None = None,
76
+ cleanup_paths: list[str] | None = None,
77
+ async_: bool = False,
78
+ hermes_context: dict | None = None,
79
+ child_timeout_seconds: float | None = None,
80
+ ) -> DispatchResult:
81
+ """Dispatch one task to a named subagent.
82
+
83
+ `goal` and `context` are concatenated client-side (Hermes' rule:
84
+ the parent passes everything). `toolsets` / `role` / `max_iterations`
85
+ become soft system-prompt hints — AgentMint sandboxes can't
86
+ structurally enforce them.
87
+
88
+ Async dispatches return immediately with a `run_id`; poll
89
+ completion via `run_status(run_id)`.
90
+
91
+ If `child_timeout_seconds` is set, the call is wrapped with a hard
92
+ cap (floor 30s); on expiry, `agent.cancel` is fired and
93
+ `DispatchTimeout` is raised.
94
+ """
95
+ prompt = compose_prompt(goal, context, toolsets, role, max_iterations)
96
+ params: dict[str, Any] = {"name": agent_name, "prompt": prompt}
97
+ if files:
98
+ params["files"] = files
99
+ if cleanup_paths:
100
+ params["cleanup_paths"] = cleanup_paths
101
+ if async_:
102
+ params["async"] = True
103
+ if hermes_context:
104
+ params["metadata"] = {"hermes": hermes_context}
105
+
106
+ if child_timeout_seconds is None:
107
+ result = self.client.call("agent.run", params)
108
+ return DispatchResult.from_dict(result or {})
109
+
110
+ cap = max(float(child_timeout_seconds), CHILD_TIMEOUT_FLOOR)
111
+ with ThreadPoolExecutor(max_workers=1) as ex:
112
+ future = ex.submit(self.client.call, "agent.run", params)
113
+ try:
114
+ result = future.result(timeout=cap)
115
+ except FutureTimeoutError as e:
116
+ try:
117
+ self.client.call("agent.cancel", {"name": agent_name})
118
+ except Exception:
119
+ pass
120
+ raise DispatchTimeout(
121
+ f"dispatch of {agent_name!r} exceeded {cap}s; agent.cancel issued"
122
+ ) from e
123
+ return DispatchResult.from_dict(result or {})
124
+
125
+ # ----- batch dispatch ------------------------------------------------
126
+
127
+ def dispatch_batch(
128
+ self,
129
+ tasks: list[Task],
130
+ max_concurrent_children: int = 3,
131
+ child_timeout_seconds: float | None = None,
132
+ cancel_event: threading.Event | None = None,
133
+ ) -> list[DispatchResult]:
134
+ """Fan out N tasks in parallel against named subagents.
135
+
136
+ - Results are returned in **input order** (matches Hermes' contract
137
+ "Results are sorted by task index to match input order regardless
138
+ of completion order").
139
+ - Up to `max_concurrent_children` run simultaneously
140
+ (`ThreadPoolExecutor`).
141
+ - On exception per task, a `DispatchResult` with `status="failed"`
142
+ (or `"timeout"` / `"interrupted"`) is inserted at that index — the
143
+ batch never aborts partial.
144
+ - `cancel_event` (a `threading.Event`) — when set, in-flight tasks
145
+ receive a best-effort `agent.cancel` and remaining tasks raise
146
+ `DispatchInterrupted`.
147
+ """
148
+ if max_concurrent_children < 1:
149
+ raise ValueError("max_concurrent_children must be >= 1")
150
+ if not tasks:
151
+ return []
152
+
153
+ results: list[DispatchResult | None] = [None] * len(tasks)
154
+
155
+ def run_one(idx: int, task: Task) -> None:
156
+ if cancel_event is not None and cancel_event.is_set():
157
+ results[idx] = DispatchResult(
158
+ status="interrupted",
159
+ extra={"error": "cancel_event set before task started"},
160
+ )
161
+ return
162
+ try:
163
+ results[idx] = self.dispatch(
164
+ agent_name=task.agent_name,
165
+ goal=task.goal,
166
+ context=task.context,
167
+ toolsets=task.toolsets,
168
+ role=task.role,
169
+ max_iterations=task.max_iterations,
170
+ files=task.files,
171
+ cleanup_paths=task.cleanup_paths,
172
+ hermes_context=task.hermes_context,
173
+ child_timeout_seconds=child_timeout_seconds,
174
+ )
175
+ except DispatchTimeout as e:
176
+ results[idx] = DispatchResult(status="timeout", extra={"error": str(e)})
177
+ except DispatchInterrupted as e:
178
+ results[idx] = DispatchResult(status="interrupted", extra={"error": str(e)})
179
+ except Exception as e:
180
+ results[idx] = DispatchResult(
181
+ status="failed",
182
+ extra={"error": str(e), "type": type(e).__name__},
183
+ )
184
+
185
+ watcher: threading.Thread | None = None
186
+ with ThreadPoolExecutor(max_workers=max_concurrent_children) as ex:
187
+ futures = [ex.submit(run_one, i, t) for i, t in enumerate(tasks)]
188
+
189
+ if cancel_event is not None:
190
+ def watch() -> None:
191
+ cancel_event.wait()
192
+ for f in futures:
193
+ f.cancel()
194
+ seen: set[str] = set()
195
+ for t in tasks:
196
+ if t.agent_name in seen:
197
+ continue
198
+ seen.add(t.agent_name)
199
+ try:
200
+ self.client.call("agent.cancel", {"name": t.agent_name})
201
+ except Exception:
202
+ pass
203
+
204
+ watcher = threading.Thread(target=watch, daemon=True)
205
+ watcher.start()
206
+
207
+ for f in futures:
208
+ try:
209
+ f.result()
210
+ except CancelledError:
211
+ # The cancel_event watcher cancelled this pending future
212
+ # before run_one had a chance to set results[idx]. Leave
213
+ # the slot as None; the synthesizer below fills it.
214
+ pass
215
+
216
+ # Fill any None slots — either because the watcher cancelled a
217
+ # pending future, or (defensively) some other untracked path.
218
+ cancelled = cancel_event is not None and cancel_event.is_set()
219
+ fallback_status = "interrupted" if cancelled else "failed"
220
+ return [
221
+ r if r is not None else DispatchResult(
222
+ status=fallback_status,
223
+ extra={"error": f"task did not start ({fallback_status})"},
224
+ )
225
+ for r in results
226
+ ]
@@ -0,0 +1,28 @@
1
+ from typing import Any
2
+
3
+
4
+ class AgentMintError(Exception):
5
+ def __init__(self, message: str, data: Any = None):
6
+ super().__init__(message)
7
+ self.data = data
8
+
9
+
10
+ class DispatchTimeout(AgentMintError):
11
+ """Raised when a child_timeout_seconds limit fires for a single dispatch.
12
+
13
+ The adapter has already issued `agent.cancel` to the AgentMint server
14
+ by the time this is raised; the cancellation is best-effort (the
15
+ underlying run may still complete depending on timing).
16
+ """
17
+
18
+
19
+ class DispatchInterrupted(AgentMintError):
20
+ """Raised inside a batch dispatch when the caller's cancel_event was set."""
21
+
22
+
23
+ class UnsupportedToolset(AgentMintError):
24
+ """Raised when the caller requests a Hermes toolset that AgentMint does
25
+ not yet support (e.g. `"web"` in v0.2 — there is no canonical AgentMint
26
+ web-fetch skill yet; the three supported harnesses have built-in web
27
+ capability but we don't expose it as a structured toolset).
28
+ """
@@ -0,0 +1,213 @@
1
+ """Monkey-patch Hermes' `tools.async_delegation.dispatch_async_delegation` so
2
+ every `delegate_task(background=True, single-task)` routes to a named,
3
+ persistent AgentMint subagent.
4
+
5
+ Sync `delegate_task` is untouched (Hermes-native fan-out / batch behaviour
6
+ preserved). Multi-task `background=True` is rejected upstream in Hermes
7
+ itself, so we never see it.
8
+
9
+ Completion is delivered exclusively via **polling** against AgentMint's
10
+ `agent.run.status` endpoint (Bearer-only, free). No public HTTPS endpoint,
11
+ no webhook secret, no HTTP route to register.
12
+ """
13
+ import logging
14
+ import threading
15
+ import time
16
+ from collections.abc import Callable
17
+ from typing import Any
18
+
19
+ from .dispatcher import AgentMintDispatcher
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ _TERMINAL_STATUSES = frozenset({"completed", "failed", "cancelled", "timeout"})
24
+
25
+
26
+ def install_delegate_task_wrapper(
27
+ dispatcher: AgentMintDispatcher,
28
+ default_agent_name: str,
29
+ poll_interval: float = 5.0,
30
+ ) -> Callable[[], None]:
31
+ """Patch Hermes' async-delegation rail to route through AgentMint.
32
+
33
+ Must be called ONCE at gateway startup, BEFORE any
34
+ `delegate_task(background=True)` call. Returns a callable that
35
+ reverses the patch (useful in tests / shutdown).
36
+
37
+ Parameters
38
+ ----------
39
+ dispatcher : AgentMintDispatcher
40
+ Pre-built dispatcher with auth attached.
41
+ default_agent_name : str
42
+ Name of the pre-minted AgentMint subagent every background
43
+ delegation routes to. The subagent's persistent `/workspace`
44
+ accumulates context across all delegations.
45
+ poll_interval : float
46
+ Seconds between `agent.run.status` polls (default 5.0). The
47
+ polling thread uses exponential backoff on errors up to 60s.
48
+ """
49
+ try:
50
+ import tools.async_delegation as _ad
51
+ except ImportError as e:
52
+ raise RuntimeError(
53
+ "Hermes module 'tools.async_delegation' not importable. "
54
+ "Run install_delegate_task_wrapper() inside the same Python "
55
+ "environment + process as Hermes' gateway."
56
+ ) from e
57
+
58
+ original = _ad.dispatch_async_delegation
59
+
60
+ def patched(**kwargs: Any) -> dict:
61
+ goal = kwargs.get("goal", "")
62
+ context = kwargs.get("context")
63
+ toolsets = kwargs.get("toolsets")
64
+ role = kwargs.get("role")
65
+ model = kwargs.get("model")
66
+ session_key = kwargs.get("session_key", "")
67
+
68
+ try:
69
+ result = dispatcher.dispatch(
70
+ agent_name=default_agent_name,
71
+ goal=goal,
72
+ context=context,
73
+ toolsets=toolsets,
74
+ role=role or "leaf",
75
+ async_=True,
76
+ hermes_context={"session_key": session_key, "model": model},
77
+ )
78
+ run_id = result.run_id or result.delegation_id
79
+ if not run_id:
80
+ raise RuntimeError("AgentMint async dispatch returned no run_id")
81
+
82
+ _spawn_poller(
83
+ dispatcher=dispatcher,
84
+ run_id=run_id,
85
+ goal=goal,
86
+ context=context,
87
+ session_key=session_key,
88
+ poll_interval=poll_interval,
89
+ )
90
+
91
+ return {
92
+ "status": "dispatched",
93
+ "delegation_id": run_id,
94
+ "goal": goal,
95
+ "mode": "background",
96
+ "source": "agentmint",
97
+ }
98
+ except Exception:
99
+ logger.exception(
100
+ "agentmint patched dispatch failed — falling back to Hermes-native"
101
+ )
102
+ return original(**kwargs)
103
+
104
+ _ad.dispatch_async_delegation = patched
105
+ logger.info(
106
+ "agentmint-hermes: installed delegate_task wrapper "
107
+ "(default_agent=%s, poll_interval=%.1fs)",
108
+ default_agent_name, poll_interval,
109
+ )
110
+
111
+ def uninstall() -> None:
112
+ _ad.dispatch_async_delegation = original
113
+
114
+ return uninstall
115
+
116
+
117
+ def _spawn_poller(
118
+ *,
119
+ dispatcher: AgentMintDispatcher,
120
+ run_id: str,
121
+ goal: str,
122
+ context: str | None,
123
+ session_key: str,
124
+ poll_interval: float,
125
+ ) -> threading.Thread:
126
+ """Background daemon thread: polls `agent.run.status` until terminal,
127
+ then pushes a Hermes async_delegation completion event onto Hermes'
128
+ completion_queue.
129
+
130
+ Returns the thread (mostly for tests). Exits on terminal status or
131
+ after a hard cap of 30 minutes — the AgentMint run's own 30-minute
132
+ server-side TTL means the record disappears anyway past that point.
133
+ """
134
+ HARD_CAP_SECONDS = 30 * 60
135
+
136
+ def push_completion(status: str, payload: dict) -> None:
137
+ try:
138
+ from tools.async_delegation import _push_completion_event
139
+ _push_completion_event(
140
+ delegation_id=run_id,
141
+ status=status,
142
+ result=payload,
143
+ )
144
+ return
145
+ except Exception:
146
+ logger.warning(
147
+ "agentmint-hermes: _push_completion_event unavailable; "
148
+ "falling back to direct queue.put"
149
+ )
150
+ try:
151
+ from hermes.gateway.process_registry import completion_queue
152
+ completion_queue.put({
153
+ "type": "async_delegation",
154
+ "delegation_id": run_id,
155
+ "status": status,
156
+ "result": payload,
157
+ "task_source": {
158
+ "goal": goal,
159
+ "context": context,
160
+ "session_key": session_key,
161
+ "source": "agentmint",
162
+ },
163
+ })
164
+ except Exception:
165
+ logger.exception(
166
+ "agentmint-hermes: completion_queue push failed; "
167
+ "completion will not re-inject"
168
+ )
169
+
170
+ def loop() -> None:
171
+ backoff = poll_interval
172
+ started = time.monotonic()
173
+ while True:
174
+ time.sleep(backoff)
175
+ if time.monotonic() - started > HARD_CAP_SECONDS:
176
+ logger.warning(
177
+ "agentmint-hermes: poller for %s exceeded 30-minute cap; "
178
+ "emitting timeout completion", run_id,
179
+ )
180
+ push_completion("timeout", {"task_source": {
181
+ "goal": goal, "context": context, "session_key": session_key,
182
+ }})
183
+ return
184
+ try:
185
+ resp = dispatcher.run_status(run_id)
186
+ status = (resp or {}).get("status", "pending")
187
+ if status in _TERMINAL_STATUSES:
188
+ push_completion(status, {
189
+ "billed_usdc": (resp or {}).get("billed_usdc"),
190
+ "completed_at": (resp or {}).get("completed_at"),
191
+ "task_source": {
192
+ "goal": goal,
193
+ "context": context,
194
+ "session_key": session_key,
195
+ },
196
+ })
197
+ return
198
+ # Reset backoff on a successful poll (status still pending).
199
+ backoff = poll_interval
200
+ except Exception:
201
+ logger.exception(
202
+ "agentmint-hermes: poller iteration failed for %s; "
203
+ "backing off", run_id,
204
+ )
205
+ backoff = min(backoff * 1.5, 60.0)
206
+
207
+ t = threading.Thread(
208
+ target=loop,
209
+ daemon=True,
210
+ name=f"agentmint-poll-{run_id[:12]}",
211
+ )
212
+ t.start()
213
+ return t
@@ -0,0 +1,61 @@
1
+ from dataclasses import dataclass, field, fields
2
+ from typing import Any
3
+
4
+
5
+ @dataclass
6
+ class AgentRecord:
7
+ agent_id: str
8
+ name: str | None = None
9
+ mode: str | None = None
10
+ harness: str | None = None
11
+ model: str | None = None
12
+ runtime: str | None = None
13
+ size: str | None = None
14
+ created_at: int | None = None
15
+ last_used: int | None = None
16
+ lifetime_runs: int | None = None
17
+ lifetime_billed_usdc: float | None = None
18
+ extra: dict[str, Any] = field(default_factory=dict)
19
+
20
+ @classmethod
21
+ def from_dict(cls, d: dict) -> "AgentRecord":
22
+ known = {f.name for f in fields(cls)} - {"extra"}
23
+ kwargs = {k: v for k, v in d.items() if k in known}
24
+ extra = {k: v for k, v in d.items() if k not in known}
25
+ return cls(**kwargs, extra=extra)
26
+
27
+
28
+ @dataclass
29
+ class DispatchResult:
30
+ status: str | None = None
31
+ delegation_id: str | None = None
32
+ run_id: str | None = None
33
+ result: Any = None
34
+ extra: dict[str, Any] = field(default_factory=dict)
35
+
36
+ @classmethod
37
+ def from_dict(cls, d: dict) -> "DispatchResult":
38
+ known = {f.name for f in fields(cls)} - {"extra"}
39
+ kwargs = {k: v for k, v in d.items() if k in known}
40
+ extra = {k: v for k, v in d.items() if k not in known}
41
+ return cls(**kwargs, extra=extra)
42
+
43
+
44
+ @dataclass
45
+ class Task:
46
+ """One task in a `dispatch_batch` call.
47
+
48
+ Mirrors Hermes' batch-task shape (`{goal, context, toolsets}`) plus an
49
+ explicit `agent_name` — each AgentMint task targets a named, persistent
50
+ subagent (which is the inversion of Hermes' "fresh AIAgent per task").
51
+ """
52
+
53
+ goal: str
54
+ agent_name: str
55
+ context: str | None = None
56
+ toolsets: list[str] | None = None
57
+ max_iterations: int | None = None
58
+ role: str = "leaf"
59
+ files: list[dict] | None = None
60
+ cleanup_paths: list[str] | None = None
61
+ hermes_context: dict | None = None
@@ -0,0 +1,93 @@
1
+ """Translate Hermes `delegate_task` semantics into AgentMint /a2a calls.
2
+
3
+ The translation is deliberately client-side and prompt-based:
4
+
5
+ - AgentMint's `agent.run` has a single `prompt` field. Hermes' `goal`
6
+ + `context` are concatenated into that prompt under labelled sections.
7
+ - AgentMint sandboxes always have terminal + filesystem. Restrictions
8
+ can't be enforced structurally — they become soft system-prompt hints
9
+ ("Do not run shell commands.").
10
+ - Role (leaf vs orchestrator) and max_iterations are similarly soft —
11
+ the harness reads them out of the prompt if it knows to.
12
+
13
+ The `"web"` toolset is **unsupported in v0.2**. AgentMint's three
14
+ harnesses (claude-code / codex / opencode) all have a built-in WebFetch
15
+ tool, but we don't yet ship a Hermes-symmetric web-fetch skill that the
16
+ adapter can advertise or restrict — passing `"web"` in toolsets raises
17
+ `UnsupportedToolset`. Track: GitHub issues on agentmint-hermes.
18
+
19
+ This mirrors Hermes' load-bearing rule: "The parent agent must pass
20
+ everything the subagent needs in the call" — but where Hermes spawns a
21
+ fresh AIAgent, AgentMint dispatches to a persistent sandbox whose
22
+ `/workspace/MEMORY.md` accumulates context across calls.
23
+ """
24
+
25
+ from .exceptions import UnsupportedToolset
26
+
27
+ # Web is intentionally excluded — see module docstring + UnsupportedToolset.
28
+ DEFAULT_TOOLSETS: tuple[str, ...] = ("terminal", "file")
29
+
30
+ TOOLSET_RESTRICTION_HINTS: dict[str, str] = {
31
+ "terminal": "Do not run shell commands.",
32
+ "file": "Do not read or write files outside /workspace.",
33
+ }
34
+
35
+ UNSUPPORTED_TOOLSETS: frozenset[str] = frozenset({"web"})
36
+
37
+ ROLE_HINTS: dict[str, str] = {
38
+ "leaf": (
39
+ "You are a leaf subagent. Focus on completing this task end-to-end. "
40
+ "Do not delegate further."
41
+ ),
42
+ "orchestrator": (
43
+ "You are an orchestrator subagent. You may break complex subtasks "
44
+ "into further delegations if your installed skills allow it."
45
+ ),
46
+ }
47
+
48
+
49
+ def compose_prompt(
50
+ goal: str,
51
+ context: str | None = None,
52
+ toolsets: list[str] | None = None,
53
+ role: str | None = None,
54
+ max_iterations: int | None = None,
55
+ ) -> str:
56
+ """Concat goal + context + soft-enforcement hints into one prompt string.
57
+
58
+ `toolsets` is interpreted as "the only allowed toolsets" — any default
59
+ toolset NOT listed becomes a restriction hint. Pass `None` to mean
60
+ "no restrictions" (Hermes default). Listing `"web"` raises
61
+ `UnsupportedToolset`.
62
+ """
63
+ if not goal or not goal.strip():
64
+ raise ValueError("goal is required")
65
+
66
+ if toolsets is not None:
67
+ unsupported = sorted(t for t in toolsets if t in UNSUPPORTED_TOOLSETS)
68
+ if unsupported:
69
+ raise UnsupportedToolset(
70
+ f"toolset(s) {unsupported!r} not supported in this version — "
71
+ f"AgentMint does not yet ship a Hermes-symmetric skill for them. "
72
+ f"Drop them from the toolsets list to proceed."
73
+ )
74
+
75
+ sections: list[str] = [f"## Goal\n{goal.strip()}"]
76
+ if context and context.strip():
77
+ sections.append(f"## Context\n{context.strip()}")
78
+
79
+ hints: list[str] = []
80
+ if toolsets is not None:
81
+ restricted = [t for t in DEFAULT_TOOLSETS if t not in toolsets]
82
+ hints.extend(TOOLSET_RESTRICTION_HINTS[t] for t in restricted)
83
+ if role:
84
+ if role not in ROLE_HINTS:
85
+ raise ValueError(f"unknown role: {role!r} (expected 'leaf' or 'orchestrator')")
86
+ hints.append(ROLE_HINTS[role])
87
+ if max_iterations:
88
+ hints.append(f"Soft iteration budget: ~{max_iterations} actions.")
89
+
90
+ if hints:
91
+ sections.append("## Constraints\n" + "\n".join(f"- {h}" for h in hints))
92
+
93
+ return "\n\n".join(sections)
@@ -0,0 +1,117 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentmint-hermes-runner
3
+ Version: 0.4.0
4
+ Summary: Route Hermes delegate_task(background=True) to named, persistent AgentMint subagents.
5
+ Project-URL: Homepage, https://github.com/mesutcelik/agentmint-hermes
6
+ Project-URL: Repository, https://github.com/mesutcelik/agentmint-hermes
7
+ Project-URL: Issues, https://github.com/mesutcelik/agentmint-hermes/issues
8
+ Author: AgentMint
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: agentmint,ai-agents,delegation,hermes,mpp,stripe-link,subagents,tempo
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Requires-Python: >=3.10
20
+ Requires-Dist: httpx>=0.27
21
+ Provides-Extra: dev
22
+ Requires-Dist: pytest>=8.0; extra == 'dev'
23
+ Requires-Dist: ruff>=0.7; extra == 'dev'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # agentmint-hermes-runner
27
+
28
+ Route Hermes `delegate_task(background=True)` to named, persistent AgentMint subagents.
29
+
30
+ > Positioning + full quickstart in [`agentmint-hermes/SKILL.md`](agentmint-hermes/SKILL.md).
31
+
32
+ ## Status
33
+
34
+ **v0.4.0** — alpha. Auth backends: `BearerAuth` (Stripe-Link), `TempoAuth` (Tempo USDC.e). Polling-only delivery. Hermes feature coverage matrix in [`agentmint-hermes/SKILL.md`](agentmint-hermes/SKILL.md).
35
+
36
+ ## Install in Hermes
37
+
38
+ ```bash
39
+ hermes skills install mesutcelik/agentmint-hermes/agentmint-hermes
40
+ ```
41
+
42
+ (The third segment is the skill subfolder inside the repo, per Hermes' `GitHubSource` convention.)
43
+
44
+ ## Three-line Hermes wiring (Strategy B)
45
+
46
+ ```python
47
+ import os
48
+ from agentmint_hermes_runner import (
49
+ AgentMintDispatcher, BearerAuth, install_delegate_task_wrapper,
50
+ )
51
+
52
+ dispatcher = AgentMintDispatcher(auth=BearerAuth(jwt=os.environ["AGENTMINT_JWT"]))
53
+ install_delegate_task_wrapper(dispatcher, default_agent_name="default-worker")
54
+ ```
55
+
56
+ Every `delegate_task(background=True)` inside Hermes now routes to AgentMint's `default-worker` subagent. Its `/workspace/MEMORY.md` accumulates across every delegation. No HTTPS, no ngrok, no webhook secret — a daemon thread polls `agent.run.status` (free, Bearer-only) every 5 s and pushes completions onto Hermes' `completion_queue` directly. Server-side requires AgentMint API ≥ 0.7.0 for the polling endpoint.
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ pip install agentmint-hermes-runner
62
+ ```
63
+
64
+ ## Surface
65
+
66
+ ```python
67
+ from agentmint_hermes_runner import (
68
+ AgentMintDispatcher,
69
+ AgentMintWebhookReceiver,
70
+ BearerAuth, TempoAuth,
71
+ Task,
72
+ )
73
+
74
+ dispatcher = AgentMintDispatcher(
75
+ auth=BearerAuth(jwt=os.environ["AGENTMINT_JWT"]),
76
+ webhook_url="https://my-gateway.example.com/agentmint-webhook", # optional
77
+ )
78
+
79
+ # Single dispatch (Hermes delegate_task analog):
80
+ result = dispatcher.dispatch(
81
+ agent_name="reviewer-myrepo",
82
+ goal="Review the diff in /workspace/pr-42 and flag risks.",
83
+ context="Project at /workspace, Python 3.11, uses Flask + PyJWT.",
84
+ toolsets=["terminal", "file"], # "web" raises UnsupportedToolset in v0.2
85
+ role="leaf", # or "orchestrator"
86
+ max_iterations=50,
87
+ child_timeout_seconds=600, # floor 30s; fires agent.cancel on expiry
88
+ )
89
+
90
+ # Batch dispatch (Hermes tasks=[…] analog):
91
+ results = dispatcher.dispatch_batch(
92
+ tasks=[
93
+ Task(agent_name="researcher-wasm", goal="WASM 2026 survey", context="…"),
94
+ Task(agent_name="researcher-riscv", goal="RISC-V 2026 survey", context="…"),
95
+ ],
96
+ max_concurrent_children=3,
97
+ child_timeout_seconds=900,
98
+ )
99
+ # results in input order; failed/timeout/interrupted statuses returned in-band
100
+ ```
101
+
102
+ ## Test
103
+
104
+ ```bash
105
+ pip install -e ".[dev]"
106
+ pytest
107
+ ruff check .
108
+ ```
109
+
110
+ ## Known unsupported (v0.2)
111
+
112
+ - **`toolsets=["web"]`** — no canonical AgentMint web-fetch skill yet. The supported harnesses (claude-code / codex / opencode) all have built-in web access via the harness itself, but we don't expose a Hermes-symmetric toolset for it. Raises `UnsupportedToolset` at compose time so the gap is loud, not silent.
113
+ - **`max_spawn_depth`** — AgentMint sandboxes aren't structurally bounded by depth.
114
+
115
+ ## License
116
+
117
+ MIT
@@ -0,0 +1,15 @@
1
+ agentmint_hermes_runner/__init__.py,sha256=CBpLrbDWN_J63Z42JkEMh65YRkyWIjMUH3bkYu5W5n8,918
2
+ agentmint_hermes_runner/client.py,sha256=P1rKGTic5UwyRHxF8jR7lj1s_oGKlOa6fumXis85FMA,1232
3
+ agentmint_hermes_runner/dispatcher.py,sha256=UWunqBBjAz1xHF9MXA10yGBS_o1zUksVcpYqWyk5KI4,9272
4
+ agentmint_hermes_runner/exceptions.py,sha256=vw6elB4i52NYffsF7v1gETV7qJU0MmLvgqPPlBNiO0k,973
5
+ agentmint_hermes_runner/hermes_patch.py,sha256=yOXWRj4IB2O7asutm9v7sT2D6TT5LPTu9132EYaYyPk,7462
6
+ agentmint_hermes_runner/models.py,sha256=H5xwrlbY3zFDsveSAls95YSY4K_DfE4ER6z7HhOXvgU,1888
7
+ agentmint_hermes_runner/translation.py,sha256=XBOZnlErWi_HXU6YBWhnwz7tCqk58tEd7h5ye5t6X_o,3721
8
+ agentmint_hermes_runner/auth/__init__.py,sha256=LpafSzNF3sKekADwwvXGUHqn24ayKIlETsAehP33gdE,130
9
+ agentmint_hermes_runner/auth/base.py,sha256=4Xn88CqujJhWTbOGuf7n42SX8gEgx-htlPH_WdSb4UE,620
10
+ agentmint_hermes_runner/auth/bearer.py,sha256=gsVvU696T2bgS5uzSAPbnHRyq21E9Je8WOqnV0XIUrE,952
11
+ agentmint_hermes_runner/auth/tempo.py,sha256=66kZkpVwgLEh9D2HE15IJwc_p20mRTS_XbsD2WvvmfA,1226
12
+ agentmint_hermes_runner-0.4.0.dist-info/METADATA,sha256=MWtrc7-gFzbz2Y6JULBDdO49nVPAZqT0tY302Syi7QY,4257
13
+ agentmint_hermes_runner-0.4.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
14
+ agentmint_hermes_runner-0.4.0.dist-info/licenses/LICENSE,sha256=I6Rrtz19ACJO9Vv8pJa8UpRIB2OCqkiswyGqm0qomXA,1066
15
+ agentmint_hermes_runner-0.4.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AgentMint
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.