agentic-relay 3.8.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.
@@ -0,0 +1,165 @@
1
+ /**
2
+ * Conversation driver (spec §6) — runs ONE agent's full qualify→deliver loop in
3
+ * isolation and returns a compact Deliverable. The agent's raw transcript never
4
+ * leaves this module (only the distilled Deliverable does), which is the whole
5
+ * point: keep transcripts out of the orchestrator's context.
6
+ *
7
+ * Completion detection prefers the backend flags (spec §10:
8
+ * `deliverable_complete` / `awaiting_input`) and falls back to a prose heuristic
9
+ * when an older backend doesn't send them.
10
+ */
11
+
12
+ import { sessionStart, sessionMessage, sessionEnd } from "./api.mjs";
13
+ import { extractJsonObject } from "./parse.mjs";
14
+ import { normalizeDeliverable, hasSubstance } from "./deliverable.mjs";
15
+
16
+ const OFFER_PHRASES = [
17
+ "reply with",
18
+ "let me know",
19
+ "want me to",
20
+ "which would you like",
21
+ "tell me",
22
+ "could you",
23
+ "can you provide",
24
+ "do you want",
25
+ "would you like",
26
+ "please share",
27
+ "please provide",
28
+ "to confirm",
29
+ ];
30
+
31
+ /** Does this turn end expecting the user to respond before the agent proceeds? */
32
+ export function turnAwaitsInput(turn) {
33
+ if (turn.deliverable_complete === true) return false; // explicit: done
34
+ if (turn.awaiting_input === true) return true; // explicit: waiting
35
+ if (Array.isArray(turn.pending_fields) && turn.pending_fields.length > 0) return true;
36
+ // Heuristic fallback on prose (spec §6 step 3).
37
+ const msg = String(turn.message || "").trim().toLowerCase();
38
+ if (msg.endsWith("?")) return true;
39
+ return OFFER_PHRASES.some((p) => msg.includes(p));
40
+ }
41
+
42
+ /**
43
+ * Decide whether the loop can stop. `hasDeliverable` = we've already parsed a
44
+ * substantive Deliverable from this (or a prior) turn. Pure + unit-tested.
45
+ */
46
+ export function assessTurn(turn, hasDeliverable) {
47
+ if (turn.deliverable_complete === true) {
48
+ return { complete: true, reason: "flag:deliverable_complete" };
49
+ }
50
+ const awaiting = turnAwaitsInput(turn);
51
+ if (!awaiting && hasDeliverable) {
52
+ return { complete: true, reason: "deliverable+not-awaiting" };
53
+ }
54
+ return { complete: false, reason: awaiting ? "awaiting-input" : "no-deliverable" };
55
+ }
56
+
57
+ const SCHEMA_LINE =
58
+ "{verdict, install:[], env:[{name,required,desc}], endpoints:[{name,method,url,notes}], " +
59
+ "snippets:[{lang,path,desc,code}], gotchas:[], free_tier, docs:[]}";
60
+
61
+ function buildPrimaryMessage(want, context, deliverNow) {
62
+ const ctx = context ? JSON.stringify(context) : "(none provided)";
63
+ return (
64
+ "You are replying to an automated coding agent, NOT a human — your reply is parsed by a program. " +
65
+ `Deliverable needed: ${want}. ` +
66
+ `Stack/context: ${ctx}. ` +
67
+ "Reply with ONLY one JSON object — no prose, no markdown, no ``` fences — matching this schema: " +
68
+ SCHEMA_LINE +
69
+ ". Hard rules: keep the ENTIRE reply under ~450 words; each `snippets[].code` ≤ 25 lines " +
70
+ "(reference the docs URL instead of pasting full multi-file implementations); use real newlines in " +
71
+ "code, never literal \\n; assume sensible defaults for the stack and do NOT ask questions." +
72
+ (deliverNow ? " Deliver the complete JSON now." : "")
73
+ );
74
+ }
75
+
76
+ function buildFollowup() {
77
+ return (
78
+ "Return the complete deliverable now as ONE JSON object only — no prose, no markdown, no fences — " +
79
+ "under ~450 words, each snippet ≤ 25 lines. Assume sensible defaults; do not ask anything further."
80
+ );
81
+ }
82
+
83
+ /** Trim a prose message to a short excerpt for --raw / fallback verdict. */
84
+ function excerpt(msg, max = 1200) {
85
+ const s = String(msg || "").trim();
86
+ return s.length > max ? s.slice(0, max) + "…" : s;
87
+ }
88
+
89
+ /**
90
+ * Run the full loop for one capability.
91
+ *
92
+ * @param resolved { capabilityId, adUnitId, clearingPriceCents, slug, name }
93
+ * @param opts { want, context, deliverNow, raw, maxTurns, timeoutMs, apiKey }
94
+ * @returns { deliverable, sessionId, turns }
95
+ */
96
+ export async function consultOne(resolved, opts) {
97
+ const { capabilityId, adUnitId, clearingPriceCents, slug, name } = resolved;
98
+ const { want, context, deliverNow, raw, maxTurns = 6, timeoutMs, apiKey } = opts;
99
+ const meta = { slug, name, capability_id: capabilityId, ad_unit_id: adUnitId };
100
+
101
+ let turns = 0;
102
+ let sessionId = null;
103
+ let bestDeliverable = null;
104
+ let lastMessage = "";
105
+
106
+ const consider = (turn) => {
107
+ const obj = extractJsonObject(turn.message);
108
+ if (obj) {
109
+ const d = normalizeDeliverable(obj, { ...meta, turns });
110
+ if (hasSubstance(d)) bestDeliverable = d;
111
+ }
112
+ };
113
+
114
+ // 1. Start the session (context as initial_data so the greeting is relevant).
115
+ const start = await sessionStart(
116
+ {
117
+ capabilityId,
118
+ adUnitId,
119
+ clearingPriceCents,
120
+ initialData: context ? { ...context, task: want } : { task: want },
121
+ },
122
+ { apiKey, timeoutMs },
123
+ );
124
+ sessionId = start.session_id;
125
+ turns = 1;
126
+ lastMessage = start.message || "";
127
+ consider(start);
128
+
129
+ // 2. Drive: send the want + output contract, then loop until complete.
130
+ let nextMessage = buildPrimaryMessage(want, context, deliverNow);
131
+ try {
132
+ while (turns < maxTurns) {
133
+ const resp = await sessionMessage({ sessionId, message: nextMessage }, { apiKey, timeoutMs });
134
+ turns += 1;
135
+ lastMessage = resp.message || lastMessage;
136
+ consider(resp);
137
+
138
+ const { complete } = assessTurn(resp, Boolean(bestDeliverable));
139
+ if (complete) break;
140
+ nextMessage = buildFollowup();
141
+ }
142
+ } finally {
143
+ // 3. Always release the session (best-effort; spec §6 step 6).
144
+ if (sessionId) {
145
+ try {
146
+ await sessionEnd({ sessionId }, { apiKey, timeoutMs });
147
+ } catch {
148
+ /* non-fatal */
149
+ }
150
+ }
151
+ }
152
+
153
+ // 4. Finalize. If no structured deliverable surfaced, fall back to a prose
154
+ // excerpt as the verdict (and raw_excerpt when --raw).
155
+ if (!bestDeliverable) {
156
+ bestDeliverable = normalizeDeliverable(
157
+ { verdict: excerpt(lastMessage, 400) },
158
+ { ...meta, turns, raw_excerpt: raw ? excerpt(lastMessage) : null },
159
+ );
160
+ } else if (raw) {
161
+ bestDeliverable.raw_excerpt = excerpt(lastMessage);
162
+ }
163
+
164
+ return { deliverable: bestDeliverable, sessionId, turns };
165
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * agentrelay fetch — download a paid deliverable artifact and unpack it into a
3
+ * subfolder of the working directory.
4
+ *
5
+ * v1 is deliberately dumb: the artifact is an OPAQUE blob. If it's a zip we
6
+ * extract it; otherwise we drop the file in as-is. We NEVER execute anything
7
+ * from the bundle (no install scripts, no running code) — there is no content
8
+ * scanning yet, so contents are inert files for the user/agent to inspect.
9
+ *
10
+ * Security: verify sha256 before unpacking; detect zip by magic bytes (not
11
+ * extension); reject any archive entry that escapes the destination (zip-slip);
12
+ * never extract into the project root.
13
+ */
14
+
15
+ import {
16
+ writeFileSync,
17
+ mkdirSync,
18
+ existsSync,
19
+ rmSync,
20
+ readdirSync,
21
+ statSync,
22
+ } from "fs";
23
+ import { join, resolve, basename, sep } from "path";
24
+ import { tmpdir } from "os";
25
+ import { createHash } from "crypto";
26
+ import { execFileSync } from "child_process";
27
+
28
+ const ZIP_MAGIC = Buffer.from([0x50, 0x4b, 0x03, 0x04]); // "PK\x03\x04"
29
+
30
+ export function isZip(buf) {
31
+ return buf.length >= 4 && buf.subarray(0, 4).equals(ZIP_MAGIC);
32
+ }
33
+
34
+ export function sha256Hex(buf) {
35
+ return createHash("sha256").update(buf).digest("hex");
36
+ }
37
+
38
+ /** Slugify for a default destination folder name. */
39
+ export function slugify(input) {
40
+ return (input || "")
41
+ .toLowerCase()
42
+ .replace(/[^a-z0-9]+/g, "-")
43
+ .replace(/^-+|-+$/g, "")
44
+ .slice(0, 60);
45
+ }
46
+
47
+ /** Infer a folder slug from an explicit filename or a URL path. */
48
+ export function inferSlug(url, filename) {
49
+ if (filename) {
50
+ const stem = basename(filename).replace(/\.[^.]+$/, "");
51
+ const s = slugify(stem);
52
+ if (s) return s;
53
+ }
54
+ try {
55
+ const p = new URL(url).pathname;
56
+ const last = decodeURIComponent(p.split("/").filter(Boolean).pop() || "");
57
+ const s = slugify(last.replace(/\.[^.]+$/, ""));
58
+ if (s) return s;
59
+ } catch {
60
+ /* not a URL we can parse */
61
+ }
62
+ return "deliverable";
63
+ }
64
+
65
+ /** Pick a non-colliding destination dir: ./<base>, then ./<base>-1, -2, … */
66
+ export function uniqueDir(base) {
67
+ let dir = resolve(base);
68
+ if (!existsSync(dir)) return dir;
69
+ for (let i = 1; i < 1000; i++) {
70
+ const cand = resolve(`${base}-${i}`);
71
+ if (!existsSync(cand)) return cand;
72
+ }
73
+ throw new Error(`could not find a free destination near ${base}`);
74
+ }
75
+
76
+ /**
77
+ * Validate that an `unzip -l` listing contains no zip-slip entries (absolute
78
+ * paths or `..` traversal that escapes dest). Returns the list of entry names.
79
+ * Pure-ish: takes the raw listing text so it's unit-testable.
80
+ */
81
+ export function zipEntriesFromListing(listingText) {
82
+ // `unzip -l` columns: Length Date Time Name — name is everything after
83
+ // the 4th whitespace-delimited column. Skip header/footer lines.
84
+ const names = [];
85
+ for (const line of listingText.split("\n")) {
86
+ const m = line.match(/^\s*\d+\s+\S+\s+\S+\s+(.+)$/);
87
+ if (m && m[1] && m[1] !== "Name") names.push(m[1].trim());
88
+ }
89
+ return names;
90
+ }
91
+
92
+ export function assertNoZipSlip(entryNames, destDir) {
93
+ const destRoot = resolve(destDir) + sep;
94
+ for (const name of entryNames) {
95
+ if (name.startsWith("/") || name.includes("\0")) {
96
+ throw new Error(`unsafe archive entry (absolute path): ${name}`);
97
+ }
98
+ const target = resolve(destDir, name);
99
+ if (target !== resolve(destDir) && !target.startsWith(destRoot)) {
100
+ throw new Error(`unsafe archive entry (path traversal / zip-slip): ${name}`);
101
+ }
102
+ }
103
+ }
104
+
105
+ function hasUnzip() {
106
+ try {
107
+ execFileSync("unzip", ["-v"], { stdio: "ignore" });
108
+ return true;
109
+ } catch {
110
+ return false;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Download + unpack. opts: { url, dest?, checksum?, filename?, fetchImpl? }.
116
+ * Returns { ok, dest, kind, filename, checksum_sha256, placed: [...] }.
117
+ */
118
+ export async function cmdFetch(opts) {
119
+ const { url, checksum = null, filename = null } = opts;
120
+ if (!url || typeof url !== "string") {
121
+ const e = new Error("usage: agentrelay fetch <download_url> [dest] [--checksum <sha256>] [--filename <name>]");
122
+ e.usage = true;
123
+ throw e;
124
+ }
125
+
126
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
127
+ const res = await fetchImpl(url);
128
+ if (!res.ok) throw new Error(`download failed: HTTP ${res.status}`);
129
+ const buf = Buffer.from(await res.arrayBuffer());
130
+
131
+ // Integrity: verify BEFORE we write anything to disk.
132
+ const digest = sha256Hex(buf);
133
+ if (checksum && digest.toLowerCase() !== String(checksum).toLowerCase()) {
134
+ throw new Error(`checksum mismatch: expected ${checksum}, got ${digest}`);
135
+ }
136
+
137
+ const slug = inferSlug(url, filename);
138
+ const dest = uniqueDir(opts.dest || `./${slug}`);
139
+
140
+ if (isZip(buf)) {
141
+ if (!hasUnzip()) {
142
+ throw new Error("`unzip` is required to extract a zip artifact but was not found on PATH");
143
+ }
144
+ const tmpZip = join(tmpdir(), `agent-relay-fetch-${digest.slice(0, 12)}.zip`);
145
+ writeFileSync(tmpZip, buf);
146
+ try {
147
+ // Zip-slip guard: inspect entries before extracting.
148
+ const listing = execFileSync("unzip", ["-l", tmpZip], { encoding: "utf-8" });
149
+ const entries = zipEntriesFromListing(listing);
150
+ assertNoZipSlip(entries, dest);
151
+
152
+ mkdirSync(dest, { recursive: true });
153
+ // -o overwrite within the freshly-created dest only; -d targets dest.
154
+ execFileSync("unzip", ["-o", "-q", tmpZip, "-d", dest], { stdio: "ignore" });
155
+ } finally {
156
+ try { rmSync(tmpZip, { force: true }); } catch { /* ignore */ }
157
+ }
158
+ return {
159
+ ok: true,
160
+ kind: "zip",
161
+ dest,
162
+ checksum_sha256: digest,
163
+ placed: listPlaced(dest),
164
+ };
165
+ }
166
+
167
+ // Non-zip: drop the single file into dest as-is.
168
+ mkdirSync(dest, { recursive: true });
169
+ const outName = filename ? basename(filename) : basename(new URL(url).pathname) || "artifact";
170
+ const outPath = join(dest, outName);
171
+ writeFileSync(outPath, buf);
172
+ return {
173
+ ok: true,
174
+ kind: "file",
175
+ dest,
176
+ filename: outName,
177
+ checksum_sha256: digest,
178
+ placed: [outPath],
179
+ };
180
+ }
181
+
182
+ function listPlaced(dir, acc = []) {
183
+ for (const name of readdirSync(dir)) {
184
+ const p = join(dir, name);
185
+ if (statSync(p).isDirectory()) listPlaced(p, acc);
186
+ else acc.push(p);
187
+ }
188
+ return acc;
189
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Lenient JSON parsing for the AttentionMarket upstream API.
3
+ *
4
+ * The capability-session agent returns markdown + code blocks inside the
5
+ * `message` string. Those payloads sometimes contain lone backslashes or raw
6
+ * control characters that make strict `JSON.parse` throw (spec §2). Never let a
7
+ * parse error crash a batch — degrade gracefully instead.
8
+ *
9
+ * Strategy (in order):
10
+ * 1. plain JSON.parse
11
+ * 2. JSON.parse after doubling lone backslashes (those not starting a valid escape)
12
+ * 3. JSON.parse after also escaping raw control chars that appear *inside* strings
13
+ * 4. caller falls back to field extraction (extractFields) as a last resort
14
+ */
15
+
16
+ /** Double backslashes that aren't part of a valid JSON escape sequence. */
17
+ function fixLoneBackslashes(s) {
18
+ return s.replace(/\\(?!["\\/bfnrtu])/g, "\\\\");
19
+ }
20
+
21
+ /**
22
+ * Escape raw control characters (U+0000–U+001F) that occur inside a JSON string
23
+ * literal. Tracks quote/escape state so structural whitespace between tokens is
24
+ * left untouched.
25
+ */
26
+ function escapeControlCharsInStrings(s) {
27
+ let out = "";
28
+ let inStr = false;
29
+ let esc = false;
30
+ for (let i = 0; i < s.length; i++) {
31
+ const ch = s[i];
32
+ const code = s.charCodeAt(i);
33
+ if (esc) {
34
+ out += ch;
35
+ esc = false;
36
+ continue;
37
+ }
38
+ if (ch === "\\") {
39
+ out += ch;
40
+ esc = true;
41
+ continue;
42
+ }
43
+ if (ch === '"') {
44
+ inStr = !inStr;
45
+ out += ch;
46
+ continue;
47
+ }
48
+ if (inStr && code < 0x20) {
49
+ if (ch === "\n") out += "\\n";
50
+ else if (ch === "\r") out += "\\r";
51
+ else if (ch === "\t") out += "\\t";
52
+ else out += "\\u" + code.toString(16).padStart(4, "0");
53
+ continue;
54
+ }
55
+ out += ch;
56
+ }
57
+ return out;
58
+ }
59
+
60
+ /**
61
+ * Parse a possibly-malformed JSON string. Returns
62
+ * `{ ok: true, value, method }` or `{ ok: false, error }`. Never throws.
63
+ */
64
+ export function lenientParse(text) {
65
+ if (typeof text !== "string") {
66
+ // Already an object (e.g. response.json() succeeded upstream).
67
+ return { ok: true, value: text, method: "passthrough" };
68
+ }
69
+ const attempts = [
70
+ ["strict", (s) => s],
71
+ ["fixed_backslashes", fixLoneBackslashes],
72
+ ["escaped_controls", (s) => escapeControlCharsInStrings(fixLoneBackslashes(s))],
73
+ ];
74
+ let lastErr;
75
+ for (const [method, transform] of attempts) {
76
+ try {
77
+ return { ok: true, value: JSON.parse(transform(text)), method };
78
+ } catch (err) {
79
+ lastErr = err;
80
+ }
81
+ }
82
+ return { ok: false, error: lastErr };
83
+ }
84
+
85
+ /**
86
+ * Pull the first balanced JSON object out of free text — handles ```json
87
+ * fences and objects embedded in prose. Returns the parsed object or null.
88
+ */
89
+ export function extractJsonObject(text) {
90
+ if (typeof text !== "string") return null;
91
+
92
+ // Prefer a fenced ```json block if present.
93
+ const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
94
+ const candidates = [];
95
+ if (fence) candidates.push(fence[1]);
96
+
97
+ // Then any balanced {...} span (greedy from first { to matching }).
98
+ const start = text.indexOf("{");
99
+ if (start !== -1) {
100
+ let depth = 0;
101
+ let inStr = false;
102
+ let esc = false;
103
+ for (let i = start; i < text.length; i++) {
104
+ const ch = text[i];
105
+ if (esc) { esc = false; continue; }
106
+ if (ch === "\\") { esc = true; continue; }
107
+ if (ch === '"') { inStr = !inStr; continue; }
108
+ if (inStr) continue;
109
+ if (ch === "{") depth++;
110
+ else if (ch === "}") {
111
+ depth--;
112
+ if (depth === 0) {
113
+ candidates.push(text.slice(start, i + 1));
114
+ break;
115
+ }
116
+ }
117
+ }
118
+ }
119
+
120
+ for (const c of candidates) {
121
+ const parsed = lenientParse(c);
122
+ if (parsed.ok && parsed.value && typeof parsed.value === "object") {
123
+ return parsed.value;
124
+ }
125
+ }
126
+ return null;
127
+ }
128
+
129
+ /**
130
+ * Last-resort field extraction: pull `"key": "..."` string values from raw text
131
+ * when the whole payload won't parse. Only handles flat string fields.
132
+ */
133
+ export function extractFields(text, keys) {
134
+ const out = {};
135
+ if (typeof text !== "string") return out;
136
+ for (const key of keys) {
137
+ const re = new RegExp(`"${key}"\\s*:\\s*"((?:[^"\\\\]|\\\\.)*)"`);
138
+ const m = text.match(re);
139
+ if (m) {
140
+ try {
141
+ out[key] = JSON.parse(`"${m[1]}"`);
142
+ } catch {
143
+ out[key] = m[1];
144
+ }
145
+ }
146
+ }
147
+ return out;
148
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Minimal promise pool — runs `worker(item)` over `items` with at most
3
+ * `concurrency` in flight. Resolves to results in input order. A worker that
4
+ * rejects yields `{ error }` in that slot so one failure never sinks the batch
5
+ * (consult-batch turns these into `failures[]` entries).
6
+ */
7
+ export async function pool(items, concurrency, worker) {
8
+ const results = new Array(items.length);
9
+ let next = 0;
10
+
11
+ async function run() {
12
+ for (;;) {
13
+ const i = next++;
14
+ if (i >= items.length) return;
15
+ try {
16
+ results[i] = { ok: true, value: await worker(items[i], i) };
17
+ } catch (error) {
18
+ results[i] = { ok: false, error };
19
+ }
20
+ }
21
+ }
22
+
23
+ const n = Math.max(1, Math.min(concurrency || 1, items.length || 1));
24
+ await Promise.all(Array.from({ length: n }, run));
25
+ return results;
26
+ }