@agentprojectcontext/apx 1.30.1 → 1.31.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agentprojectcontext/apx",
3
- "version": "1.30.1",
3
+ "version": "1.31.0",
4
4
  "description": "APX — unified CLI + daemon for the Agent Project Context (APC) standard.",
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -271,6 +271,11 @@ export function buildSuperAgentSystem({
271
271
  // Pre-rendered "# Hilos activos en otros canales" block (recency-based
272
272
  // cross-channel awareness; see core/memory/active-threads.js). "" → omitted.
273
273
  activeThreadsBlock = "",
274
+ // Compact "# Tools adicionales (activación on-demand)" block: instructions +
275
+ // the NAMES (no schemas) of tools that exist but aren't loaded on this
276
+ // channel, so the model knows they're reachable via discover_tools without
277
+ // paying for their schemas. "" → omitted (full channels load everything).
278
+ lazyToolsBlock = "",
274
279
  }) {
275
280
  const sa = globalConfig.super_agent;
276
281
  const projectIndex = projects
@@ -305,6 +310,7 @@ export function buildSuperAgentSystem({
305
310
  projectIndex || "(no projects registered)",
306
311
  buildProjectAgentsBlock(channelMeta?.projectPath),
307
312
  buildSkillsCatalog(listSkills),
313
+ lazyToolsBlock,
308
314
  voiceModeBlock,
309
315
  systemSuffix,
310
316
  ]
@@ -178,6 +178,25 @@ export async function runAgent({
178
178
  })
179
179
  : rawHandlers;
180
180
 
181
+ // Lazy tools: when the super-agent runs a `discover_tools` activation, its
182
+ // handler pushes the newly-revealed schemas onto session.pending. We drain
183
+ // that queue into effectiveSchemas at the top of each iteration, so tools
184
+ // activated on step N are callable from step N+1. No session → no-op.
185
+ const toolSession = toolHandlerCtx?.toolSession || null;
186
+ const drainPendingTools = () => {
187
+ if (!toolSession || toolSession.pending.length === 0) return;
188
+ const seen = new Set(
189
+ effectiveSchemas.map((s) => s?.function?.name || s?.name)
190
+ );
191
+ const additions = [];
192
+ for (const sc of toolSession.pending) {
193
+ const n = sc?.function?.name || sc?.name;
194
+ if (n && !seen.has(n)) { additions.push(sc); seen.add(n); }
195
+ }
196
+ toolSession.pending = [];
197
+ if (additions.length > 0) effectiveSchemas = effectiveSchemas.concat(additions);
198
+ };
199
+
181
200
  const conversation = [...previousMessages, { role: "user", content: prompt }];
182
201
  const trace = [];
183
202
  let totalUsage = { input_tokens: 0, output_tokens: 0 };
@@ -236,6 +255,8 @@ export async function runAgent({
236
255
  };
237
256
 
238
257
  for (let iter = 0; iter < maxIters; iter++) {
258
+ // Merge any tools activated via discover_tools on the previous iteration.
259
+ drainPendingTools();
239
260
  await emitProgress(onEvent, { type: "model_start", iteration: iter + 1, model: activeModel });
240
261
  // Force a tool call on iter 0 ONLY when the user message looks like a real
241
262
  // action request ("listame…", "mandá…", "buscá…"). For chit-chat ("hola",
@@ -181,19 +181,104 @@ async function ensureBrowser({ launch_options, allow_dangerous } = {}) {
181
181
  return _page;
182
182
  }
183
183
 
184
+ // ---------------------------------------------------------------------------
185
+ // Context-destruction resilience
186
+ // ---------------------------------------------------------------------------
187
+
188
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
189
+
190
+ // Puppeteer throws this family of errors when an action (evaluate / get_text /
191
+ // click / …) runs while the page is navigating, redirecting, or reloading —
192
+ // the frame's JS execution context is torn down mid-call. Redirect-heavy sites
193
+ // (ESPN geo/consent hops, login walls) trigger it constantly. These are
194
+ // transient: waiting for the navigation to settle and retrying succeeds.
195
+ const CONTEXT_DESTROYED_RE =
196
+ /Execution context was destroyed|Cannot find context|Execution context is not available|detached frame|frame (?:was|got) detached|Target closed|Session closed|Protocol error.*(?:Runtime|Page)\./i;
197
+
198
+ export function isContextDestroyed(err) {
199
+ return CONTEXT_DESTROYED_RE.test(String(err?.message || err));
200
+ }
201
+
202
+ // Let any in-flight navigation finish so the next action sees a stable context.
203
+ async function settlePage(page, { timeout = 5000 } = {}) {
204
+ if (!page || page.isClosed()) return;
205
+ await page.waitForNetworkIdle({ idleTime: 500, timeout }).catch(() => {});
206
+ }
207
+
208
+ // Run a page action, retrying on a transient "Execution context was destroyed"
209
+ // (and friends): wait `delayMs`, let the page settle, try again — up to
210
+ // `retries` extra attempts. Non-context errors bubble immediately.
211
+ export async function withContextRetry(fn, { retries = 2, delayMs = 1500 } = {}) {
212
+ let lastErr;
213
+ for (let attempt = 0; attempt <= retries; attempt++) {
214
+ try {
215
+ return await fn();
216
+ } catch (e) {
217
+ lastErr = e;
218
+ if (!isContextDestroyed(e) || attempt === retries) throw e;
219
+ await sleep(delayMs);
220
+ await settlePage(_page);
221
+ }
222
+ }
223
+ throw lastErr;
224
+ }
225
+
226
+ // Convenience: ensure the browser/page, then run an action under context-retry.
227
+ async function onPage(fn) {
228
+ const page = await ensureBrowser();
229
+ return withContextRetry(() => fn(page));
230
+ }
231
+
184
232
  // ---------------------------------------------------------------------------
185
233
  // Tool implementations
186
234
  // ---------------------------------------------------------------------------
187
235
 
188
- export async function browser_navigate({ url, launch_options, allow_dangerous } = {}) {
236
+ export async function browser_navigate({ url, launch_options, allow_dangerous, wait_until } = {}) {
189
237
  if (!url) throw new Error("url required");
190
238
  const page = await ensureBrowser({ launch_options, allow_dangerous });
191
- const response = await page.goto(url, { waitUntil: "networkidle2", timeout: 30000 });
239
+
240
+ const go = async (waitUntil) => {
241
+ const response = await page.goto(url, { waitUntil, timeout: 30000 });
242
+ // Some sites fire a client-side redirect/reload right after the initial
243
+ // load. Give it a beat to settle so the execution context is stable for
244
+ // the caller's NEXT tool (get_text/evaluate) instead of being destroyed
245
+ // out from under it.
246
+ await settlePage(page, { timeout: 3000 });
247
+ return response;
248
+ };
249
+
250
+ // Preferred wait strategy: networkidle2 (or caller override). On a
251
+ // context-destroyed / timeout / navigation error, fall back to the much more
252
+ // permissive "domcontentloaded" which resolves as soon as the DOM is parsed,
253
+ // before late redirects/XHR can tear the context down.
254
+ const preferred = wait_until || "networkidle2";
255
+ let response;
256
+ try {
257
+ response = await go(preferred);
258
+ } catch (e) {
259
+ const recoverable =
260
+ isContextDestroyed(e) ||
261
+ /TimeoutError|Navigation timeout|net::ERR_ABORTED|frame was detached/i.test(String(e?.message || e));
262
+ if (!recoverable || preferred === "domcontentloaded") throw e;
263
+ await sleep(1500);
264
+ response = await go("domcontentloaded");
265
+ }
266
+
267
+ // title() evaluates in-page, so it can itself throw if a redirect is still
268
+ // in flight — read it defensively (url() is sync and always safe).
269
+ let title = "";
270
+ try {
271
+ title = await withContextRetry(() => page.title(), { retries: 1, delayMs: 1000 });
272
+ } catch {
273
+ title = "";
274
+ }
275
+
192
276
  return {
193
277
  ok: true,
194
278
  url: page.url(),
195
279
  status: response?.status() ?? null,
196
- title: await page.title(),
280
+ title,
281
+ wait_until: response ? (preferred) : null,
197
282
  };
198
283
  }
199
284
 
@@ -206,12 +291,13 @@ export async function browser_screenshot({ selector, full_page = false, width, h
206
291
  });
207
292
  }
208
293
 
209
- const target = selector ? await page.$(selector) : null;
210
- if (selector && !target) throw new Error(`Element not found: ${selector}`);
211
-
212
- const buf = target
213
- ? await target.screenshot({ type: "png", encoding: "base64" })
214
- : await page.screenshot({ type: "png", encoding: "base64", fullPage: !!full_page });
294
+ const buf = await withContextRetry(async () => {
295
+ const target = selector ? await page.$(selector) : null;
296
+ if (selector && !target) throw new Error(`Element not found: ${selector}`);
297
+ return target
298
+ ? await target.screenshot({ type: "png", encoding: "base64" })
299
+ : await page.screenshot({ type: "png", encoding: "base64", fullPage: !!full_page });
300
+ });
215
301
 
216
302
  const size = Buffer.from(String(buf), "base64").length;
217
303
  if (size > MAX_SCREENSHOT_BYTES) {
@@ -248,50 +334,56 @@ export async function browser_screenshot({ selector, full_page = false, width, h
248
334
 
249
335
  export async function browser_click({ selector } = {}) {
250
336
  if (!selector) throw new Error("selector required");
251
- const page = await ensureBrowser();
252
- await page.waitForSelector(selector, { timeout: 10000 });
253
- await page.click(selector);
254
- await page.waitForNetworkIdle({ timeout: 5000 }).catch(() => {});
255
- return { ok: true, selector, url: page.url() };
337
+ return onPage(async (page) => {
338
+ await page.waitForSelector(selector, { timeout: 10000 });
339
+ await page.click(selector);
340
+ await page.waitForNetworkIdle({ timeout: 5000 }).catch(() => {});
341
+ return { ok: true, selector, url: page.url() };
342
+ });
256
343
  }
257
344
 
258
345
  export async function browser_type({ selector, text, clear = true } = {}) {
259
346
  if (!selector) throw new Error("selector required");
260
347
  if (text === undefined) throw new Error("text required");
261
- const page = await ensureBrowser();
262
- await page.waitForSelector(selector, { timeout: 10000 });
263
- await page.focus(selector);
264
- if (clear) {
265
- await page.keyboard.down("Control");
266
- await page.keyboard.press("KeyA");
267
- await page.keyboard.up("Control");
268
- await page.keyboard.press("Backspace");
269
- }
270
- await page.type(selector, String(text), { delay: 20 });
271
- return { ok: true, selector, typed: String(text).length };
348
+ return onPage(async (page) => {
349
+ await page.waitForSelector(selector, { timeout: 10000 });
350
+ await page.focus(selector);
351
+ if (clear) {
352
+ await page.keyboard.down("Control");
353
+ await page.keyboard.press("KeyA");
354
+ await page.keyboard.up("Control");
355
+ await page.keyboard.press("Backspace");
356
+ }
357
+ await page.type(selector, String(text), { delay: 20 });
358
+ return { ok: true, selector, typed: String(text).length };
359
+ });
272
360
  }
273
361
 
274
362
  export async function browser_select({ selector, value } = {}) {
275
363
  if (!selector) throw new Error("selector required");
276
364
  if (value === undefined) throw new Error("value required");
277
- const page = await ensureBrowser();
278
- await page.waitForSelector(selector, { timeout: 10000 });
279
- await page.select(selector, String(value));
280
- return { ok: true, selector, value };
365
+ return onPage(async (page) => {
366
+ await page.waitForSelector(selector, { timeout: 10000 });
367
+ await page.select(selector, String(value));
368
+ return { ok: true, selector, value };
369
+ });
281
370
  }
282
371
 
283
372
  export async function browser_hover({ selector } = {}) {
284
373
  if (!selector) throw new Error("selector required");
285
- const page = await ensureBrowser();
286
- await page.waitForSelector(selector, { timeout: 10000 });
287
- await page.hover(selector);
288
- return { ok: true, selector };
374
+ return onPage(async (page) => {
375
+ await page.waitForSelector(selector, { timeout: 10000 });
376
+ await page.hover(selector);
377
+ return { ok: true, selector };
378
+ });
289
379
  }
290
380
 
291
381
  export async function browser_evaluate({ code } = {}) {
292
382
  if (!code) throw new Error("code required");
293
- const page = await ensureBrowser();
383
+ return onPage((page) => evaluateOnPage(page, code));
384
+ }
294
385
 
386
+ async function evaluateOnPage(page, code) {
295
387
  // Install in-page console capture so evaluated code's logs come back.
296
388
  await page.evaluate(() => {
297
389
  window.__apxHelper = { logs: [], orig: { ...console } };
@@ -325,54 +417,56 @@ export async function browser_evaluate({ code } = {}) {
325
417
  }
326
418
 
327
419
  export async function browser_get_text({ selector } = {}) {
328
- const page = await ensureBrowser();
329
- const text = await page.evaluate((sel) => {
330
- const root = sel ? document.querySelector(sel) : document.body;
331
- if (!root) return null;
332
- const clone = root.cloneNode(true);
333
- for (const tag of ["script", "style", "nav", "header", "footer", "noscript"]) {
334
- for (const el of clone.querySelectorAll(tag)) el.remove();
335
- }
336
- return clone.innerText || clone.textContent || "";
337
- }, selector ?? null);
338
- if (text === null) throw new Error(`Element not found: ${selector}`);
339
- const cleaned = text.replace(/\n{3,}/g, "\n\n").trim();
340
- return {
341
- ok: true,
342
- url: page.url(),
343
- title: await page.title(),
344
- text: cleaned,
345
- chars: cleaned.length,
346
- };
420
+ return onPage(async (page) => {
421
+ const text = await page.evaluate((sel) => {
422
+ const root = sel ? document.querySelector(sel) : document.body;
423
+ if (!root) return null;
424
+ const clone = root.cloneNode(true);
425
+ for (const tag of ["script", "style", "nav", "header", "footer", "noscript"]) {
426
+ for (const el of clone.querySelectorAll(tag)) el.remove();
427
+ }
428
+ return clone.innerText || clone.textContent || "";
429
+ }, selector ?? null);
430
+ if (text === null) throw new Error(`Element not found: ${selector}`);
431
+ const cleaned = text.replace(/\n{3,}/g, "\n\n").trim();
432
+ let title = "";
433
+ try { title = await page.title(); } catch { title = ""; }
434
+ return {
435
+ ok: true,
436
+ url: page.url(),
437
+ title,
438
+ text: cleaned,
439
+ chars: cleaned.length,
440
+ };
441
+ });
347
442
  }
348
443
 
349
444
  export async function browser_get_content({ selector } = {}) {
350
- const page = await ensureBrowser();
351
- let content = selector
352
- ? await page.$eval(selector, el => el.innerHTML).catch(() => null)
353
- : await page.content();
354
- if (content === null) throw new Error(`Element not found: ${selector}`);
355
-
356
- let truncated = false;
357
- if (content.length > MAX_CONTENT_CHARS) {
358
- content = content.slice(0, MAX_CONTENT_CHARS) + "\n[TRUNCATED]";
359
- truncated = true;
360
- }
361
- return {
362
- ok: true,
363
- url: page.url(),
364
- selector: selector ?? null,
365
- chars: content.length,
366
- truncated,
367
- html: content,
368
- };
445
+ return onPage(async (page) => {
446
+ let content = selector
447
+ ? await page.$eval(selector, el => el.innerHTML).catch(() => null)
448
+ : await page.content();
449
+ if (content === null) throw new Error(`Element not found: ${selector}`);
450
+
451
+ let truncated = false;
452
+ if (content.length > MAX_CONTENT_CHARS) {
453
+ content = content.slice(0, MAX_CONTENT_CHARS) + "\n[TRUNCATED]";
454
+ truncated = true;
455
+ }
456
+ return {
457
+ ok: true,
458
+ url: page.url(),
459
+ selector: selector ?? null,
460
+ chars: content.length,
461
+ truncated,
462
+ html: content,
463
+ };
464
+ });
369
465
  }
370
466
 
371
467
  export async function browser_wait_for_selector({ selector, timeout = 30000 } = {}) {
372
468
  if (!selector) throw new Error("selector required");
373
- const page = await ensureBrowser();
374
- await page.waitForSelector(selector, { timeout });
375
- return { ok: true, selector };
469
+ return onPage((page) => page.waitForSelector(selector, { timeout }).then(() => ({ ok: true, selector })));
376
470
  }
377
471
 
378
472
  export async function browser_close() {
@@ -350,12 +350,17 @@ const TOOL_DEFINITIONS = [
350
350
  {
351
351
  name: "browser_navigate",
352
352
  category: "browser",
353
- description: "Navigate the headless browser to a URL. Launches Chromium lazily on first call.",
353
+ description: "Navigate the headless browser to a URL. Launches Chromium lazily on first call. Auto-retries and falls back to a more permissive wait strategy on redirect-heavy sites.",
354
354
  endpoint: { method: "POST", path: "/tools/browser/navigate" },
355
355
  parameters: {
356
356
  type: "object",
357
357
  properties: {
358
358
  url: { type: "string" },
359
+ wait_until: {
360
+ type: "string",
361
+ enum: ["load", "domcontentloaded", "networkidle0", "networkidle2"],
362
+ description: "Puppeteer wait strategy (default networkidle2). Use 'domcontentloaded' for slow/redirect-heavy sites; navigate auto-falls back to it on failure anyway.",
363
+ },
359
364
  launch_options: { type: "object", description: "Puppeteer launch overrides (headless, args, defaultViewport, etc.)." },
360
365
  allow_dangerous: { type: "boolean", description: "Allow dangerous launch args (--no-sandbox, --single-process, etc.)." },
361
366
  },
@@ -45,26 +45,54 @@ function extractText(html) {
45
45
  .replace(/&lt;/g, "<")
46
46
  .replace(/&gt;/g, ">")
47
47
  .replace(/&quot;/g, '"')
48
- .replace(/&#039;/g, "'")
48
+ .replace(/&#0?39;/g, "'")
49
49
  .replace(/&nbsp;/g, " ")
50
+ // Generic numeric entities (decimal &#92; and hex &#x27;) DDG sprinkles into
51
+ // titles/snippets — decode so results read cleanly.
52
+ .replace(/&#x([0-9a-f]+);/gi, (_, h) => String.fromCodePoint(parseInt(h, 16)))
53
+ .replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
50
54
  .replace(/\s{2,}/g, " ")
51
55
  .trim();
52
56
  }
53
57
 
58
+ /**
59
+ * Unwrap DuckDuckGo's result redirect. DDG no longer exposes the target URL
60
+ * directly: every result href is `//duckduckgo.com/l/?uddg=<urlencoded real
61
+ * url>&rut=...`. We pull the `uddg` param out and decode it back to the real
62
+ * destination. Plain/protocol-relative URLs are normalized to https.
63
+ */
64
+ export function unwrapDdgUrl(href) {
65
+ if (!href) return href;
66
+ const m = href.match(/[?&]uddg=([^&]+)/);
67
+ if (m) {
68
+ try {
69
+ return decodeURIComponent(m[1].replace(/&amp;/g, "&"));
70
+ } catch {
71
+ /* fall through to raw href */
72
+ }
73
+ }
74
+ if (href.startsWith("//")) return "https:" + href;
75
+ return href;
76
+ }
77
+
54
78
  /** Parse DuckDuckGo HTML results */
55
- function parseDdgResults(html, limit) {
79
+ export function parseDdgResults(html, limit) {
56
80
  const results = [];
57
- // Match result blocks: each has a link (.result__a) and snippet (.result__snippet)
58
- const blockRe = /<a[^>]+class="[^"]*result__a[^"]*"[^>]+href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
81
+ // Match result blocks: each has a link (.result__a) and snippet (.result__snippet).
82
+ // Attribute order varies (rel/class/href), so don't assume class precedes href.
83
+ const blockRe = /<a[^>]+class="[^"]*result__a[^"]*"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi;
59
84
  const snippetRe = /<a[^>]+class="[^"]*result__snippet[^"]*"[^>]*>([\s\S]*?)<\/a>/gi;
60
85
 
61
86
  const links = [];
62
87
  let m;
63
88
  while ((m = blockRe.exec(html)) !== null && links.length < limit * 2) {
64
- const href = m[1];
89
+ // DDG wraps every external link in a //duckduckgo.com/l/?uddg= redirect —
90
+ // decode it to the real target instead of discarding it (the old code
91
+ // dropped everything containing "duckduckgo.com", yielding zero results).
92
+ const url = unwrapDdgUrl(m[1]);
65
93
  const title = extractText(m[2]).trim();
66
- if (href && title && !href.startsWith("//duckduckgo") && !href.includes("duckduckgo.com")) {
67
- links.push({ url: href, title });
94
+ if (url && title && !/^https?:\/\/(?:[a-z]+\.)?duckduckgo\.com\//i.test(url) && !url.startsWith("//duckduckgo")) {
95
+ links.push({ url, title });
68
96
  }
69
97
  }
70
98
 
@@ -510,6 +510,10 @@ class ChannelPoller {
510
510
  const token = resolveBotToken(this.channel);
511
511
  const mediaDir = path.join(APX_HOME, "media");
512
512
  fs.mkdirSync(mediaDir, { recursive: true });
513
+ // Show "typing…" right away — download + transcription is the slow part of
514
+ // a voice message, and the reply-path typing (below) only starts after it,
515
+ // so without this the chat sits silent for seconds with no feedback.
516
+ const stopVoiceTyping = this._startTyping(chat_id);
513
517
  let localPath = null;
514
518
  let transcript = "";
515
519
  let transcribeError = null;
@@ -531,6 +535,7 @@ class ChannelPoller {
531
535
  this.log(`telegram[${this.channel.name}] audio transcription failed: ${e.message}`);
532
536
  }
533
537
  }
538
+ stopVoiceTyping(); // reply-path typing takes over from here
534
539
  const audioBody = transcript
535
540
  ? `[audio] ${transcript}`
536
541
  : `[audio] (transcription unavailable${transcribeError ? ": " + transcribeError : ""})`;