@poncho-ai/harness 0.27.0 → 0.28.1

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.
@@ -1,5 +1,5 @@
1
1
 
2
- > @poncho-ai/harness@0.27.0 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
2
+ > @poncho-ai/harness@0.28.1 build /home/runner/work/poncho-ai/poncho-ai/packages/harness
3
3
  > node scripts/embed-docs.js && tsup src/index.ts --format esm --dts
4
4
 
5
5
  [embed-docs] Generated poncho-docs.ts with 4 topics
@@ -8,8 +8,8 @@
8
8
  CLI tsup v8.5.1
9
9
  CLI Target: es2022
10
10
  ESM Build start
11
- ESM dist/index.js 278.61 KB
12
- ESM ⚡️ Build success in 148ms
11
+ ESM dist/index.js 288.74 KB
12
+ ESM ⚡️ Build success in 135ms
13
13
  DTS Build start
14
- DTS ⚡️ Build success in 7054ms
15
- DTS dist/index.d.ts 29.13 KB
14
+ DTS ⚡️ Build success in 6964ms
15
+ DTS dist/index.d.ts 29.62 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,22 @@
1
1
  # @poncho-ai/harness
2
2
 
3
+ ## 0.28.1
4
+
5
+ ### Patch Changes
6
+
7
+ - [`4d50ad9`](https://github.com/cesr/poncho-ai/commit/4d50ad970886c9d3635ec36a407514c91ce6a71a) Thanks [@cesr](https://github.com/cesr)! - Improve callback-run reliability and streaming across subagent workflows, including safer concurrent approval handling and parent callback retriggers.
8
+
9
+ Add context window/token reporting through run completion events, improve cron/web UI rendering and approval streaming behavior, and harden built-in web search retry/throttle behavior.
10
+
11
+ - Updated dependencies [[`4d50ad9`](https://github.com/cesr/poncho-ai/commit/4d50ad970886c9d3635ec36a407514c91ce6a71a)]:
12
+ - @poncho-ai/sdk@1.6.1
13
+
14
+ ## 0.28.0
15
+
16
+ ### Minor Changes
17
+
18
+ - [`c0ca56b`](https://github.com/cesr/poncho-ai/commit/c0ca56b54bb877d96ba8088537d6f1c7461d2a55) Thanks [@cesr](https://github.com/cesr)! - Add built-in `web_search` and `web_fetch` tools so agents can search the web and fetch page content without a browser or API keys. Remove the scaffolded `fetch-page` skill (superseded by `web_fetch`). Fix `browser_open` crash when agent projects have an older `@poncho-ai/browser` installed.
19
+
3
20
  ## 0.27.0
4
21
 
5
22
  ### Minor Changes
package/dist/index.d.ts CHANGED
@@ -188,6 +188,12 @@ interface ConversationStore {
188
188
  rename(conversationId: string, title: string): Promise<Conversation | undefined>;
189
189
  delete(conversationId: string): Promise<boolean>;
190
190
  appendSubagentResult(conversationId: string, result: PendingSubagentResult): Promise<void>;
191
+ /**
192
+ * Atomically clear `runningCallbackSince` without clobbering other fields.
193
+ * Returns the conversation as it exists after the clear (with current
194
+ * `pendingSubagentResults`).
195
+ */
196
+ clearCallbackLock(conversationId: string): Promise<Conversation | undefined>;
191
197
  }
192
198
  type StateProviderName = "local" | "memory" | "redis" | "upstash" | "dynamodb";
193
199
  interface StateConfig {
@@ -221,6 +227,7 @@ declare class InMemoryConversationStore implements ConversationStore {
221
227
  rename(conversationId: string, title: string): Promise<Conversation | undefined>;
222
228
  delete(conversationId: string): Promise<boolean>;
223
229
  appendSubagentResult(conversationId: string, result: PendingSubagentResult): Promise<void>;
230
+ clearCallbackLock(conversationId: string): Promise<Conversation | undefined>;
224
231
  }
225
232
  type ConversationSummary = {
226
233
  conversationId: string;
@@ -351,6 +358,8 @@ type BuiltInToolToggles = {
351
358
  todo_add?: boolean;
352
359
  todo_update?: boolean;
353
360
  todo_remove?: boolean;
361
+ web_search?: boolean;
362
+ web_fetch?: boolean;
354
363
  };
355
364
  interface MessagingChannelConfig {
356
365
  platform: "slack" | "resend" | "telegram";
@@ -738,6 +747,8 @@ declare class LatitudeCapture {
738
747
  */
739
748
  declare function jsonSchemaToZod(schema: JsonSchema): z.ZodType;
740
749
 
750
+ declare const createSearchTools: () => ToolDefinition[];
751
+
741
752
  /**
742
753
  * Resolve the full list of skill directories to scan.
743
754
  * Merges the defaults with any extra paths provided via config.
@@ -813,4 +824,4 @@ declare class TelemetryEmitter {
813
824
 
814
825
  declare const createSubagentTools: (manager: SubagentManager) => ToolDefinition[];
815
826
 
816
- export { type AgentFrontmatter, AgentHarness, type AgentIdentity, type AgentLimitsConfig, type AgentModelConfig, type BuiltInToolToggles, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type Conversation, type ConversationState, type ConversationStore, type ConversationSummary, type CronJobConfig, type HarnessOptions, type HarnessRunOutput, InMemoryConversationStore, InMemoryStateStore, LatitudeCapture, type LatitudeCaptureConfig, LocalMcpBridge, LocalUploadStore, type MainMemory, type McpConfig, type MemoryConfig, type MemoryStore, type MessagingChannelConfig, type ModelProviderFactory, PONCHO_UPLOAD_SCHEME, type ParsedAgent, type PendingSubagentResult, type PonchoConfig, type ProviderConfig, type RemoteMcpServerConfig, type RuntimeRenderContext, S3UploadStore, STORAGE_SCHEMA_VERSION, type SkillContextEntry, type SkillMetadata, type StateConfig, type StateProviderName, type StateStore, type StorageConfig, type SubagentManager, type SubagentResult, type SubagentSpawnResult, type SubagentSummary, type TelemetryConfig, TelemetryEmitter, type ToolAccess, type ToolCall, ToolDispatcher, type ToolExecutionResult, type UploadStore, type UploadsConfig, VercelBlobUploadStore, buildAgentDirectoryName, buildSkillContextWindow, compactMessages, createConversationStore, createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createMemoryStore, createMemoryTools, createModelProvider, createSkillTools, createStateStore, createSubagentTools, createUploadStore, createWriteTool, deriveUploadKey, ensureAgentIdentity, estimateTokens, estimateTotalTokens, findSafeSplitPoint, generateAgentId, getAgentStoreDirectory, getModelContextWindow, getPonchoStoreRoot, jsonSchemaToZod, loadPonchoConfig, loadSkillContext, loadSkillInstructions, loadSkillMetadata, normalizeScriptPolicyPath, parseAgentFile, parseAgentMarkdown, ponchoDocsTool, readSkillResource, renderAgentPrompt, resolveAgentIdentity, resolveCompactionConfig, resolveMemoryConfig, resolveSkillDirs, resolveStateConfig, slugifyStorageComponent };
827
+ export { type AgentFrontmatter, AgentHarness, type AgentIdentity, type AgentLimitsConfig, type AgentModelConfig, type BuiltInToolToggles, type CompactMessagesOptions, type CompactResult, type CompactionConfig, type Conversation, type ConversationState, type ConversationStore, type ConversationSummary, type CronJobConfig, type HarnessOptions, type HarnessRunOutput, InMemoryConversationStore, InMemoryStateStore, LatitudeCapture, type LatitudeCaptureConfig, LocalMcpBridge, LocalUploadStore, type MainMemory, type McpConfig, type MemoryConfig, type MemoryStore, type MessagingChannelConfig, type ModelProviderFactory, PONCHO_UPLOAD_SCHEME, type ParsedAgent, type PendingSubagentResult, type PonchoConfig, type ProviderConfig, type RemoteMcpServerConfig, type RuntimeRenderContext, S3UploadStore, STORAGE_SCHEMA_VERSION, type SkillContextEntry, type SkillMetadata, type StateConfig, type StateProviderName, type StateStore, type StorageConfig, type SubagentManager, type SubagentResult, type SubagentSpawnResult, type SubagentSummary, type TelemetryConfig, TelemetryEmitter, type ToolAccess, type ToolCall, ToolDispatcher, type ToolExecutionResult, type UploadStore, type UploadsConfig, VercelBlobUploadStore, buildAgentDirectoryName, buildSkillContextWindow, compactMessages, createConversationStore, createDefaultTools, createDeleteDirectoryTool, createDeleteTool, createEditTool, createMemoryStore, createMemoryTools, createModelProvider, createSearchTools, createSkillTools, createStateStore, createSubagentTools, createUploadStore, createWriteTool, deriveUploadKey, ensureAgentIdentity, estimateTokens, estimateTotalTokens, findSafeSplitPoint, generateAgentId, getAgentStoreDirectory, getModelContextWindow, getPonchoStoreRoot, jsonSchemaToZod, loadPonchoConfig, loadSkillContext, loadSkillInstructions, loadSkillMetadata, normalizeScriptPolicyPath, parseAgentFile, parseAgentMarkdown, ponchoDocsTool, readSkillResource, renderAgentPrompt, resolveAgentIdentity, resolveCompactionConfig, resolveMemoryConfig, resolveSkillDirs, resolveStateConfig, slugifyStorageComponent };
package/dist/index.js CHANGED
@@ -4211,15 +4211,16 @@ var loadRunnableScriptFunction = async (scriptPath) => {
4211
4211
  };
4212
4212
  var loadScriptModule = async (scriptPath) => {
4213
4213
  const extension = extname(scriptPath).toLowerCase();
4214
+ const cacheBust = `?t=${Date.now()}`;
4214
4215
  if (extension === ".ts" || extension === ".mts" || extension === ".cts") {
4215
- const jiti = createJiti2(import.meta.url, { interopDefault: true });
4216
- return await jiti.import(scriptPath);
4216
+ const jiti = createJiti2(import.meta.url, { interopDefault: true, moduleCache: false });
4217
+ return await jiti.import(scriptPath + cacheBust);
4217
4218
  }
4218
4219
  try {
4219
- return await import(pathToFileURL(scriptPath).href);
4220
+ return await import(pathToFileURL(scriptPath).href + cacheBust);
4220
4221
  } catch {
4221
- const jiti = createJiti2(import.meta.url, { interopDefault: true });
4222
- return await jiti.import(scriptPath);
4222
+ const jiti = createJiti2(import.meta.url, { interopDefault: true, moduleCache: false });
4223
+ return await jiti.import(scriptPath + cacheBust);
4223
4224
  }
4224
4225
  };
4225
4226
  var extractRunnableFunction = (value) => {
@@ -4258,10 +4259,218 @@ var extractRunnableFunction = (value) => {
4258
4259
  return void 0;
4259
4260
  };
4260
4261
 
4261
- // src/subagent-tools.ts
4262
+ // src/search-tools.ts
4263
+ import { load as cheerioLoad } from "cheerio";
4262
4264
  import { defineTool as defineTool5 } from "@poncho-ai/sdk";
4263
- var createSubagentTools = (manager) => [
4265
+ var SEARCH_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
4266
+ var FETCH_TIMEOUT_MS = 15e3;
4267
+ var SEARCH_MAX_RETRIES = 4;
4268
+ var SEARCH_INITIAL_DELAY_MS = 2e3;
4269
+ var SEARCH_MIN_INTERVAL_MS = 4e3;
4270
+ var SEARCH_FALLBACK_COOLDOWN_MS = 12e3;
4271
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
4272
+ var searchQueue = Promise.resolve();
4273
+ var nextSearchAllowedAt = 0;
4274
+ function parseRetryAfterMs(retryAfterHeader) {
4275
+ if (!retryAfterHeader) return SEARCH_FALLBACK_COOLDOWN_MS;
4276
+ const asSeconds = Number(retryAfterHeader);
4277
+ if (Number.isFinite(asSeconds) && asSeconds >= 0) {
4278
+ return Math.max(Math.floor(asSeconds * 1e3), SEARCH_MIN_INTERVAL_MS);
4279
+ }
4280
+ const asDate = new Date(retryAfterHeader).getTime();
4281
+ if (Number.isFinite(asDate)) {
4282
+ return Math.max(asDate - Date.now(), SEARCH_MIN_INTERVAL_MS);
4283
+ }
4284
+ return SEARCH_FALLBACK_COOLDOWN_MS;
4285
+ }
4286
+ function applyRateLimitCooldown(retryAfterHeader) {
4287
+ const cooldownMs = parseRetryAfterMs(retryAfterHeader);
4288
+ nextSearchAllowedAt = Math.max(nextSearchAllowedAt, Date.now() + cooldownMs);
4289
+ }
4290
+ async function runWithSearchThrottle(fn) {
4291
+ const previous = searchQueue;
4292
+ let release;
4293
+ searchQueue = new Promise((resolve12) => {
4294
+ release = resolve12;
4295
+ });
4296
+ await previous.catch(() => {
4297
+ });
4298
+ try {
4299
+ const waitMs = nextSearchAllowedAt - Date.now();
4300
+ if (waitMs > 0) {
4301
+ await sleep(waitMs);
4302
+ }
4303
+ const result = await fn();
4304
+ nextSearchAllowedAt = Math.max(nextSearchAllowedAt, Date.now() + SEARCH_MIN_INTERVAL_MS);
4305
+ return result;
4306
+ } finally {
4307
+ release?.();
4308
+ }
4309
+ }
4310
+ function isRetryableStatus(status) {
4311
+ return status === 429 || status === 503 || status >= 500;
4312
+ }
4313
+ async function braveSearch(query, maxResults) {
4314
+ const url = `https://search.brave.com/search?q=${encodeURIComponent(query)}`;
4315
+ let lastError;
4316
+ for (let attempt = 0; attempt < SEARCH_MAX_RETRIES; attempt++) {
4317
+ if (attempt > 0) {
4318
+ const delay = SEARCH_INITIAL_DELAY_MS * 2 ** (attempt - 1) + Math.floor(Math.random() * 500);
4319
+ await sleep(delay);
4320
+ }
4321
+ try {
4322
+ const html = await runWithSearchThrottle(async () => {
4323
+ const res = await fetch(url, {
4324
+ headers: {
4325
+ "User-Agent": SEARCH_UA,
4326
+ Accept: "text/html,application/xhtml+xml",
4327
+ "Accept-Language": "en-US,en;q=0.9"
4328
+ },
4329
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
4330
+ });
4331
+ if (!res.ok) {
4332
+ if (res.status === 429) {
4333
+ applyRateLimitCooldown(res.headers.get("retry-after"));
4334
+ }
4335
+ const error = new Error(`Search request failed (${res.status} ${res.statusText})`);
4336
+ if (isRetryableStatus(res.status)) {
4337
+ throw error;
4338
+ }
4339
+ error.retryable = false;
4340
+ throw error;
4341
+ }
4342
+ return await res.text();
4343
+ });
4344
+ return parseBraveResults(html, maxResults);
4345
+ } catch (err) {
4346
+ lastError = err instanceof Error ? err : new Error(String(err));
4347
+ if (lastError.retryable === false) {
4348
+ throw lastError;
4349
+ }
4350
+ if (attempt < SEARCH_MAX_RETRIES - 1) continue;
4351
+ }
4352
+ }
4353
+ throw lastError ?? new Error("Search failed after retries");
4354
+ }
4355
+ function parseBraveResults(html, max) {
4356
+ const $ = cheerioLoad(html);
4357
+ const results = [];
4358
+ $('div.snippet[data-type="web"]').each((_i, el) => {
4359
+ if (results.length >= max) return false;
4360
+ const $el = $(el);
4361
+ const anchor = $el.find(".result-content a").first();
4362
+ const href = anchor.attr("href") ?? "";
4363
+ if (!href.startsWith("http")) return;
4364
+ const title = $el.find(".title").first().text().trim();
4365
+ const snippet = $el.find(".generic-snippet .content").first().text().trim();
4366
+ if (title) {
4367
+ results.push({ title, url: href, snippet });
4368
+ }
4369
+ });
4370
+ return results;
4371
+ }
4372
+ var DEFAULT_MAX_LENGTH = 16e3;
4373
+ function extractReadableText($, maxLength) {
4374
+ const title = $("title").first().text().trim();
4375
+ $("script, style, noscript, nav, footer, header, aside, [role='navigation'], [role='banner'], [role='contentinfo']").remove();
4376
+ $("svg, iframe, form, button, input, select, textarea").remove();
4377
+ let root = $("article").first();
4378
+ if (!root.length) root = $("main").first();
4379
+ if (!root.length) root = $("[role='main']").first();
4380
+ if (!root.length) root = $("body").first();
4381
+ const text = root.text().replace(/[ \t]+/g, " ").replace(/\n{3,}/g, "\n\n").trim();
4382
+ const content = text.length > maxLength ? text.slice(0, maxLength) + "\n\u2026(truncated)" : text;
4383
+ return { title, content };
4384
+ }
4385
+ var createSearchTools = () => [
4386
+ defineTool5({
4387
+ name: "web_search",
4388
+ description: "Search the web and return a list of results (title, URL, snippet). Use this instead of opening a browser when you need to find information online.",
4389
+ inputSchema: {
4390
+ type: "object",
4391
+ properties: {
4392
+ query: {
4393
+ type: "string",
4394
+ description: "The search query"
4395
+ },
4396
+ max_results: {
4397
+ type: "number",
4398
+ description: "Maximum number of results to return (1-10, default 5)"
4399
+ }
4400
+ },
4401
+ required: ["query"],
4402
+ additionalProperties: false
4403
+ },
4404
+ handler: async (input) => {
4405
+ const query = typeof input.query === "string" ? input.query.trim() : "";
4406
+ if (!query) {
4407
+ return { error: "A non-empty query string is required." };
4408
+ }
4409
+ const max = Math.min(Math.max(Number(input.max_results) || 5, 1), 10);
4410
+ try {
4411
+ const results = await braveSearch(query, max);
4412
+ if (results.length === 0) {
4413
+ return { query, results: [], note: "No results found. Try rephrasing your query." };
4414
+ }
4415
+ return { query, results };
4416
+ } catch (err) {
4417
+ const msg = err instanceof Error ? err.message : String(err);
4418
+ return {
4419
+ error: `Search failed: ${msg}`,
4420
+ hint: "The search provider may be rate-limiting requests. Try again shortly, or use browser tools as a fallback."
4421
+ };
4422
+ }
4423
+ }
4424
+ }),
4264
4425
  defineTool5({
4426
+ name: "web_fetch",
4427
+ description: "Fetch a web page and return its text content (HTML tags stripped). Useful for reading articles, documentation, or any web page without opening a browser.",
4428
+ inputSchema: {
4429
+ type: "object",
4430
+ properties: {
4431
+ url: {
4432
+ type: "string",
4433
+ description: "The URL to fetch"
4434
+ },
4435
+ max_length: {
4436
+ type: "number",
4437
+ description: `Maximum character length of returned content (default ${DEFAULT_MAX_LENGTH})`
4438
+ }
4439
+ },
4440
+ required: ["url"],
4441
+ additionalProperties: false
4442
+ },
4443
+ handler: async (input) => {
4444
+ const url = typeof input.url === "string" ? input.url.trim() : "";
4445
+ if (!url) {
4446
+ return { error: 'A "url" string is required.' };
4447
+ }
4448
+ const maxLength = Math.max(Number(input.max_length) || DEFAULT_MAX_LENGTH, 1e3);
4449
+ try {
4450
+ const res = await fetch(url, {
4451
+ headers: { "User-Agent": SEARCH_UA, Accept: "text/html,application/xhtml+xml" },
4452
+ redirect: "follow",
4453
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
4454
+ });
4455
+ if (!res.ok) {
4456
+ return { url, status: res.status, error: res.statusText };
4457
+ }
4458
+ const html = await res.text();
4459
+ const $ = cheerioLoad(html);
4460
+ const { title, content } = extractReadableText($, maxLength);
4461
+ return { url, status: res.status, title, content };
4462
+ } catch (err) {
4463
+ const msg = err instanceof Error ? err.message : String(err);
4464
+ return { url, error: `Fetch failed: ${msg}` };
4465
+ }
4466
+ }
4467
+ })
4468
+ ];
4469
+
4470
+ // src/subagent-tools.ts
4471
+ import { defineTool as defineTool6 } from "@poncho-ai/sdk";
4472
+ var createSubagentTools = (manager) => [
4473
+ defineTool6({
4265
4474
  name: "spawn_subagent",
4266
4475
  description: "Spawn a subagent to work on a task in the background. Returns immediately with a subagent ID. The subagent runs independently and its result will be delivered to you as a message in the conversation when it completes.\n\nGuidelines:\n- Spawn all needed subagents in a SINGLE response (they run concurrently), then end your turn with a brief message to the user.\n- Do NOT spawn more subagents in follow-up steps. Wait for results to be delivered before deciding if more work is needed.\n- Prefer doing work yourself for simple or quick tasks. Spawn subagents for substantial, self-contained work.\n- The subagent has no memory of your conversation -- write thorough, self-contained instructions in the task.",
4267
4476
  inputSchema: {
@@ -4293,7 +4502,7 @@ var createSubagentTools = (manager) => [
4293
4502
  return { subagentId, status: "running" };
4294
4503
  }
4295
4504
  }),
4296
- defineTool5({
4505
+ defineTool6({
4297
4506
  name: "message_subagent",
4298
4507
  description: "Send a follow-up message to a completed or stopped subagent. The subagent restarts in the background and its result will be delivered to you as a message when it completes. Only works when the subagent is not currently running.",
4299
4508
  inputSchema: {
@@ -4321,7 +4530,7 @@ var createSubagentTools = (manager) => [
4321
4530
  return { subagentId: id, status: "running" };
4322
4531
  }
4323
4532
  }),
4324
- defineTool5({
4533
+ defineTool6({
4325
4534
  name: "stop_subagent",
4326
4535
  description: "Stop a running subagent. The subagent's conversation is preserved but it will stop processing. Use this to cancel work that is no longer needed.",
4327
4536
  inputSchema: {
@@ -4344,7 +4553,7 @@ var createSubagentTools = (manager) => [
4344
4553
  return { message: `Subagent "${subagentId}" has been stopped.` };
4345
4554
  }
4346
4555
  }),
4347
- defineTool5({
4556
+ defineTool6({
4348
4557
  name: "list_subagents",
4349
4558
  description: "List all subagents that have been spawned in this conversation. Returns each subagent's ID, original task, current status, and message count. Use this to look up subagent IDs before calling message_subagent or stop_subagent.",
4350
4559
  inputSchema: {
@@ -4970,6 +5179,11 @@ var AgentHarness = class _AgentHarness {
4970
5179
  if (this.isToolEnabled("delete_directory")) {
4971
5180
  this.registerIfMissing(createDeleteDirectoryTool(this.workingDir));
4972
5181
  }
5182
+ for (const tool of createSearchTools()) {
5183
+ if (this.isToolEnabled(tool.name)) {
5184
+ this.registerIfMissing(tool);
5185
+ }
5186
+ }
4973
5187
  if (this.environment === "development" && this.isToolEnabled("poncho_docs")) {
4974
5188
  this.registerIfMissing(ponchoDocsTool);
4975
5189
  }
@@ -5455,7 +5669,11 @@ var AgentHarness = class _AgentHarness {
5455
5669
  const session = new browserMod.BrowserSession(sessionId, browserCfg);
5456
5670
  this._browserSession = session;
5457
5671
  const tools = browserMod.createBrowserTools(
5458
- () => session
5672
+ () => session,
5673
+ // Backward compat: older @poncho-ai/browser versions expect a second
5674
+ // getConversationId callback. Current versions read from ToolContext
5675
+ // and ignore extra args.
5676
+ () => "__default__"
5459
5677
  );
5460
5678
  for (const tool of tools) {
5461
5679
  if (this.isToolEnabled(tool.name)) {
@@ -5733,6 +5951,8 @@ ${this.skillFingerprint}`;
5733
5951
  let totalOutputTokens = 0;
5734
5952
  let totalCachedTokens = 0;
5735
5953
  let transientStepRetryCount = 0;
5954
+ let latestContextTokens = 0;
5955
+ let toolOutputEstimateSinceModel = 0;
5736
5956
  let cachedCoreMessages = [];
5737
5957
  let convertedUpTo = 0;
5738
5958
  for (let step = 1; step <= maxSteps; step += 1) {
@@ -5762,7 +5982,9 @@ ${this.skillFingerprint}`;
5762
5982
  duration: now() - start,
5763
5983
  continuation: true,
5764
5984
  continuationMessages: [...messages],
5765
- maxSteps
5985
+ maxSteps,
5986
+ contextTokens: latestContextTokens + toolOutputEstimateSinceModel,
5987
+ contextWindow
5766
5988
  };
5767
5989
  yield pushEvent({ type: "run:completed", runId, result: result2 });
5768
5990
  return;
@@ -6002,10 +6224,13 @@ ${textContent}` };
6002
6224
  if (emittedMessages.length > 0 && emittedMessages[emittedMessages.length - 1].role === "user") {
6003
6225
  emittedMessages.pop();
6004
6226
  }
6227
+ const tokensAfterCompaction = estimateTotalTokens(integrityPrompt, messages, toolDefsJson);
6228
+ latestContextTokens = tokensAfterCompaction;
6229
+ toolOutputEstimateSinceModel = 0;
6005
6230
  yield pushEvent({
6006
6231
  type: "compaction:completed",
6007
6232
  tokensBefore: effectiveTokens,
6008
- tokensAfter: estimateTotalTokens(integrityPrompt, messages, toolDefsJson),
6233
+ tokensAfter: tokensAfterCompaction,
6009
6234
  messagesBefore: compactResult.messagesBefore,
6010
6235
  compactedMessages: emittedMessages,
6011
6236
  messagesAfter: compactResult.messagesAfter
@@ -6152,13 +6377,16 @@ ${textContent}` };
6152
6377
  const usage = await result.usage;
6153
6378
  const toolCallsResult = await result.toolCalls;
6154
6379
  const stepCachedTokens = usage.inputTokenDetails?.cacheReadTokens ?? 0;
6155
- totalInputTokens += usage.inputTokens ?? 0;
6380
+ const stepInputTokens = usage.inputTokens ?? 0;
6381
+ totalInputTokens += stepInputTokens;
6156
6382
  totalOutputTokens += usage.outputTokens ?? 0;
6157
6383
  totalCachedTokens += stepCachedTokens;
6384
+ latestContextTokens = stepInputTokens;
6385
+ toolOutputEstimateSinceModel = 0;
6158
6386
  yield pushEvent({
6159
6387
  type: "model:response",
6160
6388
  usage: {
6161
- input: usage.inputTokens ?? 0,
6389
+ input: stepInputTokens,
6162
6390
  output: usage.outputTokens ?? 0,
6163
6391
  cached: stepCachedTokens
6164
6392
  }
@@ -6204,7 +6432,9 @@ ${textContent}` };
6204
6432
  output: totalOutputTokens,
6205
6433
  cached: totalCachedTokens
6206
6434
  },
6207
- duration: now() - start
6435
+ duration: now() - start,
6436
+ contextTokens: latestContextTokens + toolOutputEstimateSinceModel,
6437
+ contextWindow
6208
6438
  };
6209
6439
  yield pushEvent({ type: "run:completed", runId, result: result2 });
6210
6440
  return;
@@ -6332,6 +6562,7 @@ ${textContent}` };
6332
6562
  span?.end({ result: { value: result2.output ?? null, isError: false } });
6333
6563
  const serialized = JSON.stringify(result2.output ?? null);
6334
6564
  const outputTokenEstimate = Math.ceil(serialized.length / 4);
6565
+ toolOutputEstimateSinceModel += outputTokenEstimate;
6335
6566
  yield pushEvent({
6336
6567
  type: "tool:completed",
6337
6568
  tool: result2.tool,
@@ -6442,7 +6673,9 @@ ${this.skillFingerprint}`;
6442
6673
  duration: now() - start,
6443
6674
  continuation: true,
6444
6675
  continuationMessages: [...messages],
6445
- maxSteps
6676
+ maxSteps,
6677
+ contextTokens: latestContextTokens + toolOutputEstimateSinceModel,
6678
+ contextWindow
6446
6679
  };
6447
6680
  yield pushEvent({ type: "run:completed", runId, result });
6448
6681
  } else {
@@ -6770,6 +7003,13 @@ var InMemoryConversationStore = class {
6770
7003
  conversation.pendingSubagentResults.push(result);
6771
7004
  conversation.updatedAt = Date.now();
6772
7005
  }
7006
+ async clearCallbackLock(conversationId) {
7007
+ const conversation = this.conversations.get(conversationId);
7008
+ if (!conversation) return void 0;
7009
+ conversation.runningCallbackSince = void 0;
7010
+ conversation.updatedAt = Date.now();
7011
+ return conversation;
7012
+ }
6773
7013
  };
6774
7014
  var FileConversationStore = class {
6775
7015
  workingDir;
@@ -6992,6 +7232,28 @@ var FileConversationStore = class {
6992
7232
  conversation.updatedAt = Date.now();
6993
7233
  await this.update(conversation);
6994
7234
  }
7235
+ async clearCallbackLock(conversationId) {
7236
+ await this.ensureLoaded();
7237
+ const summary = this.conversations.get(conversationId);
7238
+ if (!summary) return void 0;
7239
+ const { conversationsDir } = await this.resolvePaths();
7240
+ const filePath = resolve11(conversationsDir, summary.fileName);
7241
+ let result;
7242
+ this.writing = this.writing.then(async () => {
7243
+ const conv = await this.readConversationFile(summary.fileName);
7244
+ if (!conv) return;
7245
+ conv.runningCallbackSince = void 0;
7246
+ conv.updatedAt = Date.now();
7247
+ await writeJsonAtomic3(filePath, conv);
7248
+ this.conversations.set(conversationId, {
7249
+ ...summary,
7250
+ updatedAt: conv.updatedAt
7251
+ });
7252
+ result = conv;
7253
+ });
7254
+ await this.writing;
7255
+ return result;
7256
+ }
6995
7257
  };
6996
7258
  var FileStateStore = class {
6997
7259
  workingDir;
@@ -7332,6 +7594,18 @@ var KeyValueConversationStoreBase = class {
7332
7594
  await this.update(conversation);
7333
7595
  });
7334
7596
  }
7597
+ async clearCallbackLock(conversationId) {
7598
+ let result;
7599
+ await this.withAppendLock(conversationId, async () => {
7600
+ const conversation = await this.get(conversationId);
7601
+ if (!conversation) return;
7602
+ conversation.runningCallbackSince = void 0;
7603
+ conversation.updatedAt = Date.now();
7604
+ await this.update(conversation);
7605
+ result = conversation;
7606
+ });
7607
+ return result;
7608
+ }
7335
7609
  };
7336
7610
  var UpstashConversationStore = class extends KeyValueConversationStoreBase {
7337
7611
  baseUrl;
@@ -7782,7 +8056,7 @@ var TelemetryEmitter = class {
7782
8056
  };
7783
8057
 
7784
8058
  // src/index.ts
7785
- import { defineTool as defineTool6 } from "@poncho-ai/sdk";
8059
+ import { defineTool as defineTool7 } from "@poncho-ai/sdk";
7786
8060
  export {
7787
8061
  AgentHarness,
7788
8062
  InMemoryConversationStore,
@@ -7807,12 +8081,13 @@ export {
7807
8081
  createMemoryStore,
7808
8082
  createMemoryTools,
7809
8083
  createModelProvider,
8084
+ createSearchTools,
7810
8085
  createSkillTools,
7811
8086
  createStateStore,
7812
8087
  createSubagentTools,
7813
8088
  createUploadStore,
7814
8089
  createWriteTool,
7815
- defineTool6 as defineTool,
8090
+ defineTool7 as defineTool,
7816
8091
  deriveUploadKey,
7817
8092
  ensureAgentIdentity,
7818
8093
  estimateTokens,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.27.0",
3
+ "version": "0.28.1",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
@@ -26,12 +26,13 @@
26
26
  "@latitude-data/telemetry": "^2.0.4",
27
27
  "@opentelemetry/api": "1.9.0",
28
28
  "ai": "^6.0.86",
29
+ "cheerio": "^1.2.0",
29
30
  "jiti": "^2.6.1",
30
31
  "mustache": "^4.2.0",
31
32
  "redis": "^5.10.0",
32
33
  "yaml": "^2.4.0",
33
34
  "zod": "^3.22.0",
34
- "@poncho-ai/sdk": "1.6.0"
35
+ "@poncho-ai/sdk": "1.6.1"
35
36
  },
36
37
  "devDependencies": {
37
38
  "@types/mustache": "^4.2.6",
package/src/config.ts CHANGED
@@ -46,6 +46,8 @@ export type BuiltInToolToggles = {
46
46
  todo_add?: boolean;
47
47
  todo_update?: boolean;
48
48
  todo_remove?: boolean;
49
+ web_search?: boolean;
50
+ web_fetch?: boolean;
49
51
  };
50
52
 
51
53
  export interface MessagingChannelConfig {
package/src/harness.ts CHANGED
@@ -32,6 +32,7 @@ import { addPromptCacheBreakpoints } from "./prompt-cache.js";
32
32
  import { jsonSchemaToZod } from "./schema-converter.js";
33
33
  import type { SkillMetadata } from "./skill-context.js";
34
34
  import { createSkillTools, normalizeScriptPolicyPath } from "./skill-tools.js";
35
+ import { createSearchTools } from "./search-tools.js";
35
36
  import { createSubagentTools } from "./subagent-tools.js";
36
37
  import type { SubagentManager } from "./subagent-manager.js";
37
38
  import { LatitudeTelemetry } from "@latitude-data/telemetry";
@@ -562,7 +563,7 @@ export class AgentHarness {
562
563
  private insideTelemetryCapture = false;
563
564
  private _browserSession?: unknown;
564
565
  private _browserMod?: {
565
- createBrowserTools: (getSession: () => unknown) => ToolDefinition[];
566
+ createBrowserTools: (getSession: () => unknown, getConversationId?: () => string) => ToolDefinition[];
566
567
  BrowserSession: new (sessionId: string, config: Record<string, unknown>) => unknown;
567
568
  };
568
569
 
@@ -644,6 +645,11 @@ export class AgentHarness {
644
645
  if (this.isToolEnabled("delete_directory")) {
645
646
  this.registerIfMissing(createDeleteDirectoryTool(this.workingDir));
646
647
  }
648
+ for (const tool of createSearchTools()) {
649
+ if (this.isToolEnabled(tool.name)) {
650
+ this.registerIfMissing(tool);
651
+ }
652
+ }
647
653
  if (this.environment === "development" && this.isToolEnabled("poncho_docs")) {
648
654
  this.registerIfMissing(ponchoDocsTool);
649
655
  }
@@ -1161,7 +1167,7 @@ export class AgentHarness {
1161
1167
  private async initBrowserTools(config: PonchoConfig): Promise<void> {
1162
1168
  const spec = ["@poncho-ai", "browser"].join("/");
1163
1169
  let browserMod: {
1164
- createBrowserTools: (getSession: () => unknown) => ToolDefinition[];
1170
+ createBrowserTools: (getSession: () => unknown, getConversationId?: () => string) => ToolDefinition[];
1165
1171
  BrowserSession: new (sessionId: string, cfg?: Record<string, unknown>) => unknown;
1166
1172
  };
1167
1173
  try {
@@ -1205,6 +1211,10 @@ export class AgentHarness {
1205
1211
 
1206
1212
  const tools = browserMod.createBrowserTools(
1207
1213
  () => session,
1214
+ // Backward compat: older @poncho-ai/browser versions expect a second
1215
+ // getConversationId callback. Current versions read from ToolContext
1216
+ // and ignore extra args.
1217
+ () => "__default__",
1208
1218
  );
1209
1219
  for (const tool of tools) {
1210
1220
  if (this.isToolEnabled(tool.name)) {
@@ -1544,6 +1554,8 @@ ${boundedMainMemory.trim()}`
1544
1554
  let totalOutputTokens = 0;
1545
1555
  let totalCachedTokens = 0;
1546
1556
  let transientStepRetryCount = 0;
1557
+ let latestContextTokens = 0;
1558
+ let toolOutputEstimateSinceModel = 0;
1547
1559
  let cachedCoreMessages: ModelMessage[] = [];
1548
1560
  let convertedUpTo = 0;
1549
1561
 
@@ -1575,6 +1587,8 @@ ${boundedMainMemory.trim()}`
1575
1587
  continuation: true,
1576
1588
  continuationMessages: [...messages],
1577
1589
  maxSteps,
1590
+ contextTokens: latestContextTokens + toolOutputEstimateSinceModel,
1591
+ contextWindow,
1578
1592
  };
1579
1593
  yield pushEvent({ type: "run:completed", runId, result });
1580
1594
  return;
@@ -1875,10 +1889,13 @@ ${boundedMainMemory.trim()}`
1875
1889
  if (emittedMessages.length > 0 && emittedMessages[emittedMessages.length - 1].role === "user") {
1876
1890
  emittedMessages.pop();
1877
1891
  }
1892
+ const tokensAfterCompaction = estimateTotalTokens(integrityPrompt, messages, toolDefsJson);
1893
+ latestContextTokens = tokensAfterCompaction;
1894
+ toolOutputEstimateSinceModel = 0;
1878
1895
  yield pushEvent({
1879
1896
  type: "compaction:completed",
1880
1897
  tokensBefore: effectiveTokens,
1881
- tokensAfter: estimateTotalTokens(integrityPrompt, messages, toolDefsJson),
1898
+ tokensAfter: tokensAfterCompaction,
1882
1899
  messagesBefore: compactResult.messagesBefore!,
1883
1900
  compactedMessages: emittedMessages,
1884
1901
  messagesAfter: compactResult.messagesAfter!,
@@ -2050,14 +2067,17 @@ ${boundedMainMemory.trim()}`
2050
2067
 
2051
2068
  // Update token usage
2052
2069
  const stepCachedTokens = usage.inputTokenDetails?.cacheReadTokens ?? 0;
2053
- totalInputTokens += usage.inputTokens ?? 0;
2070
+ const stepInputTokens = usage.inputTokens ?? 0;
2071
+ totalInputTokens += stepInputTokens;
2054
2072
  totalOutputTokens += usage.outputTokens ?? 0;
2055
2073
  totalCachedTokens += stepCachedTokens;
2074
+ latestContextTokens = stepInputTokens;
2075
+ toolOutputEstimateSinceModel = 0;
2056
2076
 
2057
2077
  yield pushEvent({
2058
2078
  type: "model:response",
2059
2079
  usage: {
2060
- input: usage.inputTokens ?? 0,
2080
+ input: stepInputTokens,
2061
2081
  output: usage.outputTokens ?? 0,
2062
2082
  cached: stepCachedTokens,
2063
2083
  },
@@ -2110,6 +2130,8 @@ ${boundedMainMemory.trim()}`
2110
2130
  cached: totalCachedTokens,
2111
2131
  },
2112
2132
  duration: now() - start,
2133
+ contextTokens: latestContextTokens + toolOutputEstimateSinceModel,
2134
+ contextWindow,
2113
2135
  };
2114
2136
  yield pushEvent({ type: "run:completed", runId, result });
2115
2137
  return;
@@ -2280,6 +2302,7 @@ ${boundedMainMemory.trim()}`
2280
2302
  span?.end({ result: { value: result.output ?? null, isError: false } });
2281
2303
  const serialized = JSON.stringify(result.output ?? null);
2282
2304
  const outputTokenEstimate = Math.ceil(serialized.length / 4);
2305
+ toolOutputEstimateSinceModel += outputTokenEstimate;
2283
2306
  yield pushEvent({
2284
2307
  type: "tool:completed",
2285
2308
  tool: result.tool,
@@ -2405,6 +2428,8 @@ ${boundedMainMemory.trim()}`
2405
2428
  continuation: true,
2406
2429
  continuationMessages: [...messages],
2407
2430
  maxSteps,
2431
+ contextTokens: latestContextTokens + toolOutputEstimateSinceModel,
2432
+ contextWindow,
2408
2433
  };
2409
2434
  yield pushEvent({ type: "run:completed", runId, result });
2410
2435
  } else {
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from "./memory.js";
9
9
  export * from "./mcp.js";
10
10
  export * from "./model-factory.js";
11
11
  export * from "./schema-converter.js";
12
+ export * from "./search-tools.js";
12
13
  export * from "./skill-context.js";
13
14
  export * from "./skill-tools.js";
14
15
  export * from "./state.js";
@@ -0,0 +1,267 @@
1
+ import { load as cheerioLoad, type CheerioAPI } from "cheerio";
2
+ import { defineTool, type ToolDefinition } from "@poncho-ai/sdk";
3
+
4
+ const SEARCH_UA =
5
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36";
6
+
7
+ const FETCH_TIMEOUT_MS = 15_000;
8
+
9
+ const SEARCH_MAX_RETRIES = 4;
10
+ const SEARCH_INITIAL_DELAY_MS = 2_000;
11
+ const SEARCH_MIN_INTERVAL_MS = 4_000;
12
+ const SEARCH_FALLBACK_COOLDOWN_MS = 12_000;
13
+
14
+ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // web_search — Brave Search HTML scraping (no API key)
18
+ // ---------------------------------------------------------------------------
19
+
20
+ interface SearchResult {
21
+ title: string;
22
+ url: string;
23
+ snippet: string;
24
+ }
25
+
26
+ let searchQueue: Promise<void> = Promise.resolve();
27
+ let nextSearchAllowedAt = 0;
28
+
29
+ function parseRetryAfterMs(retryAfterHeader: string | null): number {
30
+ if (!retryAfterHeader) return SEARCH_FALLBACK_COOLDOWN_MS;
31
+ const asSeconds = Number(retryAfterHeader);
32
+ if (Number.isFinite(asSeconds) && asSeconds >= 0) {
33
+ return Math.max(Math.floor(asSeconds * 1000), SEARCH_MIN_INTERVAL_MS);
34
+ }
35
+ const asDate = new Date(retryAfterHeader).getTime();
36
+ if (Number.isFinite(asDate)) {
37
+ return Math.max(asDate - Date.now(), SEARCH_MIN_INTERVAL_MS);
38
+ }
39
+ return SEARCH_FALLBACK_COOLDOWN_MS;
40
+ }
41
+
42
+ function applyRateLimitCooldown(retryAfterHeader: string | null): void {
43
+ const cooldownMs = parseRetryAfterMs(retryAfterHeader);
44
+ nextSearchAllowedAt = Math.max(nextSearchAllowedAt, Date.now() + cooldownMs);
45
+ }
46
+
47
+ async function runWithSearchThrottle<T>(fn: () => Promise<T>): Promise<T> {
48
+ const previous = searchQueue;
49
+ let release: (() => void) | undefined;
50
+ searchQueue = new Promise<void>((resolve) => {
51
+ release = resolve;
52
+ });
53
+
54
+ await previous.catch(() => {});
55
+ try {
56
+ const waitMs = nextSearchAllowedAt - Date.now();
57
+ if (waitMs > 0) {
58
+ await sleep(waitMs);
59
+ }
60
+ const result = await fn();
61
+ nextSearchAllowedAt = Math.max(nextSearchAllowedAt, Date.now() + SEARCH_MIN_INTERVAL_MS);
62
+ return result;
63
+ } finally {
64
+ release?.();
65
+ }
66
+ }
67
+
68
+ function isRetryableStatus(status: number): boolean {
69
+ return status === 429 || status === 503 || status >= 500;
70
+ }
71
+
72
+ async function braveSearch(query: string, maxResults: number): Promise<SearchResult[]> {
73
+ const url = `https://search.brave.com/search?q=${encodeURIComponent(query)}`;
74
+
75
+ let lastError: Error | undefined;
76
+ for (let attempt = 0; attempt < SEARCH_MAX_RETRIES; attempt++) {
77
+ if (attempt > 0) {
78
+ const delay = SEARCH_INITIAL_DELAY_MS * 2 ** (attempt - 1) + Math.floor(Math.random() * 500);
79
+ await sleep(delay);
80
+ }
81
+
82
+ try {
83
+ const html = await runWithSearchThrottle(async () => {
84
+ const res = await fetch(url, {
85
+ headers: {
86
+ "User-Agent": SEARCH_UA,
87
+ Accept: "text/html,application/xhtml+xml",
88
+ "Accept-Language": "en-US,en;q=0.9",
89
+ },
90
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
91
+ });
92
+
93
+ if (!res.ok) {
94
+ if (res.status === 429) {
95
+ applyRateLimitCooldown(res.headers.get("retry-after"));
96
+ }
97
+ const error = new Error(`Search request failed (${res.status} ${res.statusText})`);
98
+ if (isRetryableStatus(res.status)) {
99
+ throw error;
100
+ }
101
+ // Non-retryable status: surface immediately.
102
+ (error as Error & { retryable?: boolean }).retryable = false;
103
+ throw error;
104
+ }
105
+
106
+ return await res.text();
107
+ });
108
+
109
+ return parseBraveResults(html, maxResults);
110
+ } catch (err) {
111
+ lastError = err instanceof Error ? err : new Error(String(err));
112
+ if ((lastError as Error & { retryable?: boolean }).retryable === false) {
113
+ throw lastError;
114
+ }
115
+ if (attempt < SEARCH_MAX_RETRIES - 1) continue;
116
+ }
117
+ }
118
+
119
+ throw lastError ?? new Error("Search failed after retries");
120
+ }
121
+
122
+ function parseBraveResults(html: string, max: number): SearchResult[] {
123
+ const $ = cheerioLoad(html);
124
+ const results: SearchResult[] = [];
125
+
126
+ $('div.snippet[data-type="web"]').each((_i, el) => {
127
+ if (results.length >= max) return false;
128
+
129
+ const $el = $(el);
130
+ const anchor = $el.find(".result-content a").first();
131
+ const href = anchor.attr("href") ?? "";
132
+ if (!href.startsWith("http")) return;
133
+
134
+ const title = $el.find(".title").first().text().trim();
135
+ const snippet = $el.find(".generic-snippet .content").first().text().trim();
136
+
137
+ if (title) {
138
+ results.push({ title, url: href, snippet });
139
+ }
140
+ });
141
+
142
+ return results;
143
+ }
144
+
145
+ // ---------------------------------------------------------------------------
146
+ // web_fetch — fetch a URL and extract readable text via cheerio
147
+ // ---------------------------------------------------------------------------
148
+
149
+ const DEFAULT_MAX_LENGTH = 16_000;
150
+
151
+ function extractReadableText($: CheerioAPI, maxLength: number): { title: string; content: string } {
152
+ const title = $("title").first().text().trim();
153
+
154
+ $("script, style, noscript, nav, footer, header, aside, [role='navigation'], [role='banner'], [role='contentinfo']").remove();
155
+ $("svg, iframe, form, button, input, select, textarea").remove();
156
+
157
+ let root = $("article").first();
158
+ if (!root.length) root = $("main").first();
159
+ if (!root.length) root = $("[role='main']").first();
160
+ if (!root.length) root = $("body").first();
161
+
162
+ const text = root
163
+ .text()
164
+ .replace(/[ \t]+/g, " ")
165
+ .replace(/\n{3,}/g, "\n\n")
166
+ .trim();
167
+
168
+ const content =
169
+ text.length > maxLength ? text.slice(0, maxLength) + "\n…(truncated)" : text;
170
+
171
+ return { title, content };
172
+ }
173
+
174
+ // ---------------------------------------------------------------------------
175
+ // Tool definitions
176
+ // ---------------------------------------------------------------------------
177
+
178
+ export const createSearchTools = (): ToolDefinition[] => [
179
+ defineTool({
180
+ name: "web_search",
181
+ description:
182
+ "Search the web and return a list of results (title, URL, snippet). " +
183
+ "Use this instead of opening a browser when you need to find information online.",
184
+ inputSchema: {
185
+ type: "object",
186
+ properties: {
187
+ query: {
188
+ type: "string",
189
+ description: "The search query",
190
+ },
191
+ max_results: {
192
+ type: "number",
193
+ description: "Maximum number of results to return (1-10, default 5)",
194
+ },
195
+ },
196
+ required: ["query"],
197
+ additionalProperties: false,
198
+ },
199
+ handler: async (input) => {
200
+ const query = typeof input.query === "string" ? input.query.trim() : "";
201
+ if (!query) {
202
+ return { error: "A non-empty query string is required." };
203
+ }
204
+ const max = Math.min(Math.max(Number(input.max_results) || 5, 1), 10);
205
+ try {
206
+ const results = await braveSearch(query, max);
207
+ if (results.length === 0) {
208
+ return { query, results: [], note: "No results found. Try rephrasing your query." };
209
+ }
210
+ return { query, results };
211
+ } catch (err) {
212
+ const msg = err instanceof Error ? err.message : String(err);
213
+ return {
214
+ error: `Search failed: ${msg}`,
215
+ hint: "The search provider may be rate-limiting requests. Try again shortly, or use browser tools as a fallback.",
216
+ };
217
+ }
218
+ },
219
+ }),
220
+
221
+ defineTool({
222
+ name: "web_fetch",
223
+ description:
224
+ "Fetch a web page and return its text content (HTML tags stripped). " +
225
+ "Useful for reading articles, documentation, or any web page without opening a browser.",
226
+ inputSchema: {
227
+ type: "object",
228
+ properties: {
229
+ url: {
230
+ type: "string",
231
+ description: "The URL to fetch",
232
+ },
233
+ max_length: {
234
+ type: "number",
235
+ description: `Maximum character length of returned content (default ${DEFAULT_MAX_LENGTH})`,
236
+ },
237
+ },
238
+ required: ["url"],
239
+ additionalProperties: false,
240
+ },
241
+ handler: async (input) => {
242
+ const url = typeof input.url === "string" ? input.url.trim() : "";
243
+ if (!url) {
244
+ return { error: 'A "url" string is required.' };
245
+ }
246
+ const maxLength = Math.max(Number(input.max_length) || DEFAULT_MAX_LENGTH, 1_000);
247
+
248
+ try {
249
+ const res = await fetch(url, {
250
+ headers: { "User-Agent": SEARCH_UA, Accept: "text/html,application/xhtml+xml" },
251
+ redirect: "follow",
252
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
253
+ });
254
+ if (!res.ok) {
255
+ return { url, status: res.status, error: res.statusText };
256
+ }
257
+ const html = await res.text();
258
+ const $ = cheerioLoad(html);
259
+ const { title, content } = extractReadableText($, maxLength);
260
+ return { url, status: res.status, title, content };
261
+ } catch (err) {
262
+ const msg = err instanceof Error ? err.message : String(err);
263
+ return { url, error: `Fetch failed: ${msg}` };
264
+ }
265
+ },
266
+ }),
267
+ ];
@@ -412,17 +412,19 @@ const loadRunnableScriptFunction = async (
412
412
 
413
413
  const loadScriptModule = async (scriptPath: string): Promise<unknown> => {
414
414
  const extension = extname(scriptPath).toLowerCase();
415
- // Node emits noisy warnings when attempting native ESM import on TypeScript
416
- // files in serverless environments. Use jiti first for TS entrypoints.
415
+ // Both Node's native import() and jiti cache modules by URL/path.
416
+ // Append a cache-busting query string so edits made by the agent are
417
+ // picked up on the next run_skill_script call.
418
+ const cacheBust = `?t=${Date.now()}`;
417
419
  if (extension === ".ts" || extension === ".mts" || extension === ".cts") {
418
- const jiti = createJiti(import.meta.url, { interopDefault: true });
419
- return await jiti.import(scriptPath);
420
+ const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false });
421
+ return await jiti.import(scriptPath + cacheBust);
420
422
  }
421
423
  try {
422
- return await import(pathToFileURL(scriptPath).href);
424
+ return await import(pathToFileURL(scriptPath).href + cacheBust);
423
425
  } catch {
424
- const jiti = createJiti(import.meta.url, { interopDefault: true });
425
- return await jiti.import(scriptPath);
426
+ const jiti = createJiti(import.meta.url, { interopDefault: true, moduleCache: false });
427
+ return await jiti.import(scriptPath + cacheBust);
426
428
  }
427
429
  };
428
430
 
package/src/state.ts CHANGED
@@ -84,6 +84,12 @@ export interface ConversationStore {
84
84
  rename(conversationId: string, title: string): Promise<Conversation | undefined>;
85
85
  delete(conversationId: string): Promise<boolean>;
86
86
  appendSubagentResult(conversationId: string, result: PendingSubagentResult): Promise<void>;
87
+ /**
88
+ * Atomically clear `runningCallbackSince` without clobbering other fields.
89
+ * Returns the conversation as it exists after the clear (with current
90
+ * `pendingSubagentResults`).
91
+ */
92
+ clearCallbackLock(conversationId: string): Promise<Conversation | undefined>;
87
93
  }
88
94
 
89
95
  export type StateProviderName =
@@ -325,6 +331,14 @@ export class InMemoryConversationStore implements ConversationStore {
325
331
  conversation.pendingSubagentResults.push(result);
326
332
  conversation.updatedAt = Date.now();
327
333
  }
334
+
335
+ async clearCallbackLock(conversationId: string): Promise<Conversation | undefined> {
336
+ const conversation = this.conversations.get(conversationId);
337
+ if (!conversation) return undefined;
338
+ conversation.runningCallbackSince = undefined;
339
+ conversation.updatedAt = Date.now();
340
+ return conversation;
341
+ }
328
342
  }
329
343
 
330
344
  export type ConversationSummary = {
@@ -607,6 +621,31 @@ class FileConversationStore implements ConversationStore {
607
621
  conversation.updatedAt = Date.now();
608
622
  await this.update(conversation);
609
623
  }
624
+
625
+ async clearCallbackLock(conversationId: string): Promise<Conversation | undefined> {
626
+ await this.ensureLoaded();
627
+ const summary = this.conversations.get(conversationId);
628
+ if (!summary) return undefined;
629
+ const { conversationsDir } = await this.resolvePaths();
630
+ const filePath = resolve(conversationsDir, summary.fileName);
631
+ let result: Conversation | undefined;
632
+ // Read inside the writing chain so we see the latest state after any
633
+ // pending appendSubagentResult writes have flushed.
634
+ this.writing = this.writing.then(async () => {
635
+ const conv = await this.readConversationFile(summary.fileName);
636
+ if (!conv) return;
637
+ conv.runningCallbackSince = undefined;
638
+ conv.updatedAt = Date.now();
639
+ await writeJsonAtomic(filePath, conv);
640
+ this.conversations.set(conversationId, {
641
+ ...summary,
642
+ updatedAt: conv.updatedAt,
643
+ });
644
+ result = conv;
645
+ });
646
+ await this.writing;
647
+ return result;
648
+ }
610
649
  }
611
650
 
612
651
  type LocalStateFile = {
@@ -1005,6 +1044,19 @@ abstract class KeyValueConversationStoreBase implements ConversationStore {
1005
1044
  await this.update(conversation);
1006
1045
  });
1007
1046
  }
1047
+
1048
+ async clearCallbackLock(conversationId: string): Promise<Conversation | undefined> {
1049
+ let result: Conversation | undefined;
1050
+ await this.withAppendLock(conversationId, async () => {
1051
+ const conversation = await this.get(conversationId);
1052
+ if (!conversation) return;
1053
+ conversation.runningCallbackSince = undefined;
1054
+ conversation.updatedAt = Date.now();
1055
+ await this.update(conversation);
1056
+ result = conversation;
1057
+ });
1058
+ return result;
1059
+ }
1008
1060
  }
1009
1061
 
1010
1062
  class UpstashConversationStore extends KeyValueConversationStoreBase {