@poncho-ai/harness 0.28.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.28.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 284.05 KB
12
- ESM ⚡️ Build success in 147ms
11
+ ESM dist/index.js 288.74 KB
12
+ ESM ⚡️ Build success in 135ms
13
13
  DTS Build start
14
- DTS ⚡️ Build success in 7926ms
15
- DTS dist/index.d.ts 29.26 KB
14
+ DTS ⚡️ Build success in 6964ms
15
+ DTS dist/index.d.ts 29.62 KB
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
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
+
3
14
  ## 0.28.0
4
15
 
5
16
  ### 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;
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) => {
@@ -4263,21 +4264,93 @@ import { load as cheerioLoad } from "cheerio";
4263
4264
  import { defineTool as defineTool5 } from "@poncho-ai/sdk";
4264
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";
4265
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
+ }
4266
4313
  async function braveSearch(query, maxResults) {
4267
4314
  const url = `https://search.brave.com/search?q=${encodeURIComponent(query)}`;
4268
- const res = await fetch(url, {
4269
- headers: {
4270
- "User-Agent": SEARCH_UA,
4271
- Accept: "text/html,application/xhtml+xml",
4272
- "Accept-Language": "en-US,en;q=0.9"
4273
- },
4274
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS)
4275
- });
4276
- if (!res.ok) {
4277
- throw new Error(`Search request failed (${res.status} ${res.statusText})`);
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
+ }
4278
4352
  }
4279
- const html = await res.text();
4280
- return parseBraveResults(html, maxResults);
4353
+ throw lastError ?? new Error("Search failed after retries");
4281
4354
  }
4282
4355
  function parseBraveResults(html, max) {
4283
4356
  const $ = cheerioLoad(html);
@@ -5878,6 +5951,8 @@ ${this.skillFingerprint}`;
5878
5951
  let totalOutputTokens = 0;
5879
5952
  let totalCachedTokens = 0;
5880
5953
  let transientStepRetryCount = 0;
5954
+ let latestContextTokens = 0;
5955
+ let toolOutputEstimateSinceModel = 0;
5881
5956
  let cachedCoreMessages = [];
5882
5957
  let convertedUpTo = 0;
5883
5958
  for (let step = 1; step <= maxSteps; step += 1) {
@@ -5907,7 +5982,9 @@ ${this.skillFingerprint}`;
5907
5982
  duration: now() - start,
5908
5983
  continuation: true,
5909
5984
  continuationMessages: [...messages],
5910
- maxSteps
5985
+ maxSteps,
5986
+ contextTokens: latestContextTokens + toolOutputEstimateSinceModel,
5987
+ contextWindow
5911
5988
  };
5912
5989
  yield pushEvent({ type: "run:completed", runId, result: result2 });
5913
5990
  return;
@@ -6147,10 +6224,13 @@ ${textContent}` };
6147
6224
  if (emittedMessages.length > 0 && emittedMessages[emittedMessages.length - 1].role === "user") {
6148
6225
  emittedMessages.pop();
6149
6226
  }
6227
+ const tokensAfterCompaction = estimateTotalTokens(integrityPrompt, messages, toolDefsJson);
6228
+ latestContextTokens = tokensAfterCompaction;
6229
+ toolOutputEstimateSinceModel = 0;
6150
6230
  yield pushEvent({
6151
6231
  type: "compaction:completed",
6152
6232
  tokensBefore: effectiveTokens,
6153
- tokensAfter: estimateTotalTokens(integrityPrompt, messages, toolDefsJson),
6233
+ tokensAfter: tokensAfterCompaction,
6154
6234
  messagesBefore: compactResult.messagesBefore,
6155
6235
  compactedMessages: emittedMessages,
6156
6236
  messagesAfter: compactResult.messagesAfter
@@ -6297,13 +6377,16 @@ ${textContent}` };
6297
6377
  const usage = await result.usage;
6298
6378
  const toolCallsResult = await result.toolCalls;
6299
6379
  const stepCachedTokens = usage.inputTokenDetails?.cacheReadTokens ?? 0;
6300
- totalInputTokens += usage.inputTokens ?? 0;
6380
+ const stepInputTokens = usage.inputTokens ?? 0;
6381
+ totalInputTokens += stepInputTokens;
6301
6382
  totalOutputTokens += usage.outputTokens ?? 0;
6302
6383
  totalCachedTokens += stepCachedTokens;
6384
+ latestContextTokens = stepInputTokens;
6385
+ toolOutputEstimateSinceModel = 0;
6303
6386
  yield pushEvent({
6304
6387
  type: "model:response",
6305
6388
  usage: {
6306
- input: usage.inputTokens ?? 0,
6389
+ input: stepInputTokens,
6307
6390
  output: usage.outputTokens ?? 0,
6308
6391
  cached: stepCachedTokens
6309
6392
  }
@@ -6349,7 +6432,9 @@ ${textContent}` };
6349
6432
  output: totalOutputTokens,
6350
6433
  cached: totalCachedTokens
6351
6434
  },
6352
- duration: now() - start
6435
+ duration: now() - start,
6436
+ contextTokens: latestContextTokens + toolOutputEstimateSinceModel,
6437
+ contextWindow
6353
6438
  };
6354
6439
  yield pushEvent({ type: "run:completed", runId, result: result2 });
6355
6440
  return;
@@ -6477,6 +6562,7 @@ ${textContent}` };
6477
6562
  span?.end({ result: { value: result2.output ?? null, isError: false } });
6478
6563
  const serialized = JSON.stringify(result2.output ?? null);
6479
6564
  const outputTokenEstimate = Math.ceil(serialized.length / 4);
6565
+ toolOutputEstimateSinceModel += outputTokenEstimate;
6480
6566
  yield pushEvent({
6481
6567
  type: "tool:completed",
6482
6568
  tool: result2.tool,
@@ -6587,7 +6673,9 @@ ${this.skillFingerprint}`;
6587
6673
  duration: now() - start,
6588
6674
  continuation: true,
6589
6675
  continuationMessages: [...messages],
6590
- maxSteps
6676
+ maxSteps,
6677
+ contextTokens: latestContextTokens + toolOutputEstimateSinceModel,
6678
+ contextWindow
6591
6679
  };
6592
6680
  yield pushEvent({ type: "run:completed", runId, result });
6593
6681
  } else {
@@ -6915,6 +7003,13 @@ var InMemoryConversationStore = class {
6915
7003
  conversation.pendingSubagentResults.push(result);
6916
7004
  conversation.updatedAt = Date.now();
6917
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
+ }
6918
7013
  };
6919
7014
  var FileConversationStore = class {
6920
7015
  workingDir;
@@ -7137,6 +7232,28 @@ var FileConversationStore = class {
7137
7232
  conversation.updatedAt = Date.now();
7138
7233
  await this.update(conversation);
7139
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
+ }
7140
7257
  };
7141
7258
  var FileStateStore = class {
7142
7259
  workingDir;
@@ -7477,6 +7594,18 @@ var KeyValueConversationStoreBase = class {
7477
7594
  await this.update(conversation);
7478
7595
  });
7479
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
+ }
7480
7609
  };
7481
7610
  var UpstashConversationStore = class extends KeyValueConversationStoreBase {
7482
7611
  baseUrl;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poncho-ai/harness",
3
- "version": "0.28.0",
3
+ "version": "0.28.1",
4
4
  "description": "Agent execution runtime - conversation loop, tool dispatch, streaming",
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,7 +32,7 @@
32
32
  "redis": "^5.10.0",
33
33
  "yaml": "^2.4.0",
34
34
  "zod": "^3.22.0",
35
- "@poncho-ai/sdk": "1.6.0"
35
+ "@poncho-ai/sdk": "1.6.1"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@types/mustache": "^4.2.6",
package/src/harness.ts CHANGED
@@ -1554,6 +1554,8 @@ ${boundedMainMemory.trim()}`
1554
1554
  let totalOutputTokens = 0;
1555
1555
  let totalCachedTokens = 0;
1556
1556
  let transientStepRetryCount = 0;
1557
+ let latestContextTokens = 0;
1558
+ let toolOutputEstimateSinceModel = 0;
1557
1559
  let cachedCoreMessages: ModelMessage[] = [];
1558
1560
  let convertedUpTo = 0;
1559
1561
 
@@ -1585,6 +1587,8 @@ ${boundedMainMemory.trim()}`
1585
1587
  continuation: true,
1586
1588
  continuationMessages: [...messages],
1587
1589
  maxSteps,
1590
+ contextTokens: latestContextTokens + toolOutputEstimateSinceModel,
1591
+ contextWindow,
1588
1592
  };
1589
1593
  yield pushEvent({ type: "run:completed", runId, result });
1590
1594
  return;
@@ -1885,10 +1889,13 @@ ${boundedMainMemory.trim()}`
1885
1889
  if (emittedMessages.length > 0 && emittedMessages[emittedMessages.length - 1].role === "user") {
1886
1890
  emittedMessages.pop();
1887
1891
  }
1892
+ const tokensAfterCompaction = estimateTotalTokens(integrityPrompt, messages, toolDefsJson);
1893
+ latestContextTokens = tokensAfterCompaction;
1894
+ toolOutputEstimateSinceModel = 0;
1888
1895
  yield pushEvent({
1889
1896
  type: "compaction:completed",
1890
1897
  tokensBefore: effectiveTokens,
1891
- tokensAfter: estimateTotalTokens(integrityPrompt, messages, toolDefsJson),
1898
+ tokensAfter: tokensAfterCompaction,
1892
1899
  messagesBefore: compactResult.messagesBefore!,
1893
1900
  compactedMessages: emittedMessages,
1894
1901
  messagesAfter: compactResult.messagesAfter!,
@@ -2060,14 +2067,17 @@ ${boundedMainMemory.trim()}`
2060
2067
 
2061
2068
  // Update token usage
2062
2069
  const stepCachedTokens = usage.inputTokenDetails?.cacheReadTokens ?? 0;
2063
- totalInputTokens += usage.inputTokens ?? 0;
2070
+ const stepInputTokens = usage.inputTokens ?? 0;
2071
+ totalInputTokens += stepInputTokens;
2064
2072
  totalOutputTokens += usage.outputTokens ?? 0;
2065
2073
  totalCachedTokens += stepCachedTokens;
2074
+ latestContextTokens = stepInputTokens;
2075
+ toolOutputEstimateSinceModel = 0;
2066
2076
 
2067
2077
  yield pushEvent({
2068
2078
  type: "model:response",
2069
2079
  usage: {
2070
- input: usage.inputTokens ?? 0,
2080
+ input: stepInputTokens,
2071
2081
  output: usage.outputTokens ?? 0,
2072
2082
  cached: stepCachedTokens,
2073
2083
  },
@@ -2120,6 +2130,8 @@ ${boundedMainMemory.trim()}`
2120
2130
  cached: totalCachedTokens,
2121
2131
  },
2122
2132
  duration: now() - start,
2133
+ contextTokens: latestContextTokens + toolOutputEstimateSinceModel,
2134
+ contextWindow,
2123
2135
  };
2124
2136
  yield pushEvent({ type: "run:completed", runId, result });
2125
2137
  return;
@@ -2290,6 +2302,7 @@ ${boundedMainMemory.trim()}`
2290
2302
  span?.end({ result: { value: result.output ?? null, isError: false } });
2291
2303
  const serialized = JSON.stringify(result.output ?? null);
2292
2304
  const outputTokenEstimate = Math.ceil(serialized.length / 4);
2305
+ toolOutputEstimateSinceModel += outputTokenEstimate;
2293
2306
  yield pushEvent({
2294
2307
  type: "tool:completed",
2295
2308
  tool: result.tool,
@@ -2415,6 +2428,8 @@ ${boundedMainMemory.trim()}`
2415
2428
  continuation: true,
2416
2429
  continuationMessages: [...messages],
2417
2430
  maxSteps,
2431
+ contextTokens: latestContextTokens + toolOutputEstimateSinceModel,
2432
+ contextWindow,
2418
2433
  };
2419
2434
  yield pushEvent({ type: "run:completed", runId, result });
2420
2435
  } else {
@@ -6,6 +6,13 @@ const SEARCH_UA =
6
6
 
7
7
  const FETCH_TIMEOUT_MS = 15_000;
8
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
+
9
16
  // ---------------------------------------------------------------------------
10
17
  // web_search — Brave Search HTML scraping (no API key)
11
18
  // ---------------------------------------------------------------------------
@@ -16,21 +23,100 @@ interface SearchResult {
16
23
  snippet: string;
17
24
  }
18
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
+
19
72
  async function braveSearch(query: string, maxResults: number): Promise<SearchResult[]> {
20
73
  const url = `https://search.brave.com/search?q=${encodeURIComponent(query)}`;
21
- const res = await fetch(url, {
22
- headers: {
23
- "User-Agent": SEARCH_UA,
24
- Accept: "text/html,application/xhtml+xml",
25
- "Accept-Language": "en-US,en;q=0.9",
26
- },
27
- signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
28
- });
29
- if (!res.ok) {
30
- throw new Error(`Search request failed (${res.status} ${res.statusText})`);
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
+ }
31
117
  }
32
- const html = await res.text();
33
- return parseBraveResults(html, maxResults);
118
+
119
+ throw lastError ?? new Error("Search failed after retries");
34
120
  }
35
121
 
36
122
  function parseBraveResults(html: string, max: number): SearchResult[] {
@@ -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 {