@sema-agent/core 2.12.0 → 2.13.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.
@@ -8,6 +8,7 @@ export interface AgentTranscriptToolOptions {
8
8
  owner?: string;
9
9
  scope?: string;
10
10
  sessionId?: string;
11
+ enrichCtx?: import("../core/tools.js").ToolCtxEnricher;
11
12
  }
12
13
  export declare function createAgentTranscriptTool(opts: AgentTranscriptToolOptions): import("../internal/harness-types.js").AgentTool<Type.TObject<{
13
14
  id: Type.TString;
@@ -118,5 +118,5 @@ export function createAgentTranscriptTool(opts) {
118
118
  details: { type: "agent-transcript", id, steps },
119
119
  };
120
120
  },
121
- });
121
+ }, opts.enrichCtx !== undefined ? { enrichCtx: opts.enrichCtx } : {});
122
122
  }
@@ -43,6 +43,9 @@ function emptyAssistant(model) {
43
43
  function isAbortError(err) {
44
44
  return err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError");
45
45
  }
46
+ function isWalltimeCutoff(err) {
47
+ return err instanceof Error && err.message === WALLTIME_CUTOFF_MESSAGE;
48
+ }
46
49
  function sleep(ms, signal) {
47
50
  return new Promise((resolve) => {
48
51
  if (signal?.aborted)
@@ -85,7 +88,7 @@ export function runStreamingBrain(args) {
85
88
  .catch((err) => {
86
89
  const aborted = signal?.aborted === true || isAbortError(err);
87
90
  terminalRetryPhase = "gave_up";
88
- terminalRetryDetail = aborted ? "cancelled while retrying" : "retries exhausted";
91
+ terminalRetryDetail = aborted ? "cancelled while retrying" : isWalltimeCutoff(err) ? "wall-clock deadline reached while retrying" : "retries exhausted";
89
92
  const errorMsg = emptyAssistant(model);
90
93
  errorMsg.stopReason = aborted ? "aborted" : "error";
91
94
  errorMsg.errorMessage = err instanceof Error ? err.message : String(err);
@@ -1,4 +1,5 @@
1
1
  import { extractSymbols } from "../tools/fs/repo-map.js";
2
+ import { parseSkillToolEntry } from "./skill-tool-specifier.js";
2
3
  export function decideAutoPromote(i) {
3
4
  if (i.tripwire.escalate)
4
5
  return "needs_human";
@@ -58,7 +59,7 @@ export function deriveTripwire(artifactText, declared, profileTokens) {
58
59
  const reasons = [];
59
60
  let escalate = false;
60
61
  try {
61
- const allow = new Set(declared.allowTools ?? []);
62
+ const allow = new Set((declared.allowTools ?? []).map((entry) => parseSkillToolEntry(entry).name));
62
63
  const symbols = extractSymbols(artifactText);
63
64
  for (const sym of symbols) {
64
65
  if (!allow.has(sym)) {
@@ -28,6 +28,11 @@ export async function addWorktree(baseEnv, opts) {
28
28
  const detail = add.ok ? add.value.stderr || add.value.stdout : String(add.error);
29
29
  throw new Error(`git worktree add failed: ${detail}`);
30
30
  }
31
+ const excludeLine = shq(`/${WORKTREE_PARENT}/`);
32
+ await baseEnv
33
+ .exec(`ex="$(git rev-parse --git-path info/exclude)" && mkdir -p "$(dirname "$ex")" && ` +
34
+ `{ grep -qxF ${excludeLine} "$ex" 2>/dev/null || printf '%s\\n' ${excludeLine} >> "$ex"; }`, { cwd: opts.repoRoot })
35
+ .catch(() => undefined);
31
36
  let inner;
32
37
  try {
33
38
  inner = await opts.rootEnvAt(worktreeDir);
@@ -71,6 +71,7 @@ export declare const MCP_IDLE_TIMEOUT_HTTP_DEFAULT_MS: number;
71
71
  export declare function mcpIdleTimeoutMs(kind: "stdio" | "http"): number;
72
72
  export declare function describeMcpSpecErrorCode(code: unknown): string | undefined;
73
73
  export declare function collapseMcpErrorPrefix(message: string): string;
74
+ export declare function networkErrorCode(err: unknown, depth?: number): string | undefined;
74
75
  export declare function normalizeMcpName(name: string): string;
75
76
  export declare function clampNameSegment(seg: string, max?: number): string;
76
77
  export * from "./image-downsample.js";
package/dist/core/mcp.js CHANGED
@@ -179,11 +179,11 @@ const NETWORK_CODES_NEVER_DELIVERED = new Set([
179
179
  "ENETUNREACH",
180
180
  "UND_ERR_CONNECT_TIMEOUT",
181
181
  ]);
182
- function networkErrorCode(err, depth = 0) {
182
+ export function networkErrorCode(err, depth = 0) {
183
183
  if (depth > 5 || !(err instanceof Error))
184
184
  return undefined;
185
185
  const code = err.code;
186
- if (typeof code === "string" && /^(?:E[A-Z]+|UND_ERR_[A-Z_]+)$/.test(code))
186
+ if (typeof code === "string" && /^(?:E[A-Z_]+|UND_ERR_[A-Z_]+)$/.test(code))
187
187
  return code;
188
188
  if (err instanceof AggregateError) {
189
189
  for (const inner of err.errors) {
@@ -786,7 +786,8 @@ async function readDirViaExtension(rs, uri, signal, timeoutMs, watchdog) {
786
786
  if (err instanceof McpError && err.code === ErrorCode.InvalidParams) {
787
787
  if (pages > 0)
788
788
  return { kind: "ok", resources, cursorInvalid: true };
789
- return { kind: classifyDirReadInvalidParams(err.message), detail: err.message };
789
+ const detail = collapseMcpErrorPrefix(err.message);
790
+ return { kind: classifyDirReadInvalidParams(detail), detail };
790
791
  }
791
792
  if (err instanceof McpError && err.code === ErrorCode.MethodNotFound)
792
793
  return { kind: "unsupported" };
@@ -1,5 +1,6 @@
1
1
  import { canonicalizeTarget, fileArgPath } from "../../tools/fs/safety.js";
2
2
  import { canonicalToolName } from "../tool-name-aliases.js";
3
+ import { parseSkillToolEntry, skillSpecifierRejection } from "../skill-tool-specifier.js";
3
4
  export class ActiveSkillScope {
4
5
  frames = [];
5
6
  push(frame) {
@@ -47,12 +48,19 @@ export function createActiveSkillScopePolicy(opts) {
47
48
  };
48
49
  }
49
50
  const manifests = frames.flatMap((f) => (f.kind === "manifest" ? [f.manifest] : []));
50
- let allowed;
51
- for (const m of manifests) {
52
- const here = new Set(m.allowTools.map(canonicalToolName));
53
- allowed = allowed === undefined ? here : new Set([...allowed].filter((t) => here.has(t)));
54
- }
55
- if (!allowed || !allowed.has(toolName)) {
51
+ const perFrame = manifests.map((m) => {
52
+ const byName = new Map();
53
+ for (const raw of m.allowTools) {
54
+ const entry = parseSkillToolEntry(raw);
55
+ const bucket = byName.get(entry.name);
56
+ if (bucket)
57
+ bucket.push(entry);
58
+ else
59
+ byName.set(entry.name, [entry]);
60
+ }
61
+ return { manifest: m, byName };
62
+ });
63
+ if (perFrame.length === 0 || perFrame.some((f) => !f.byName.has(toolName))) {
56
64
  const ids = manifests.map((m) => m.lineageId).join(", ");
57
65
  return {
58
66
  action: "deny",
@@ -60,6 +68,26 @@ export function createActiveSkillScopePolicy(opts) {
60
68
  decisionReason: "safety",
61
69
  };
62
70
  }
71
+ for (const frame of perFrame) {
72
+ const alternatives = frame.byName.get(toolName) ?? [];
73
+ const rejections = [];
74
+ let admitted = false;
75
+ for (const entry of alternatives) {
76
+ const rejection = skillSpecifierRejection(entry, req.args);
77
+ if (rejection === undefined) {
78
+ admitted = true;
79
+ break;
80
+ }
81
+ rejections.push(rejection);
82
+ }
83
+ if (!admitted) {
84
+ return {
85
+ action: "deny",
86
+ reason: `tool "${req.toolName}" is narrowed by skill manifest "${frame.manifest.lineageId}": ${rejections.join("; ")}`,
87
+ decisionReason: "safety",
88
+ };
89
+ }
90
+ }
63
91
  const pathConstrainingActive = manifests.some((m) => m.allowPaths && m.allowPaths.length > 0);
64
92
  if (pathConstrainingActive && !PATH_WRITE_TOOLS.has(toolName)) {
65
93
  const eff = toolEffects?.get(toolName) ?? "write";
@@ -432,6 +432,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
432
432
  }
433
433
  }
434
434
  const taskScope = internals?.registryScope ?? spec.principal ?? "default";
435
+ const offloadScope = spec.principal ?? "default";
435
436
  defaultTaskRegistry.maybeGc();
436
437
  const forgetOnThrow = () => forgetQuietly(sessions, sessionId);
437
438
  const extraBodyCollisions = reservedCollisions(model.extraBody, reservedFor(model.api));
@@ -456,7 +457,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
456
457
  const offloadEnabled = Number.isFinite(offloadThreshold) && offloadThreshold > 0;
457
458
  const rawOffloadStore = offloadEnabled ? (deps.toolResultStore ?? new InMemoryToolResultStore()) : undefined;
458
459
  const offloadStore = rawOffloadStore instanceof RunnerSharedToolResultStore
459
- ? new ScopedToolResultStore(rawOffloadStore, taskScope)
460
+ ? new ScopedToolResultStore(rawOffloadStore, offloadScope)
460
461
  : rawOffloadStore;
461
462
  const offloadReachableToolsRef = {};
462
463
  const maybeOffload = (tool, perTool) => {
@@ -1384,6 +1385,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1384
1385
  owner: hostTaskId,
1385
1386
  scope: taskScope,
1386
1387
  ...(sessionId !== undefined ? { sessionId } : {}),
1388
+ enrichCtx: enrichSpecToolCtx,
1387
1389
  })));
1388
1390
  }
1389
1391
  }
@@ -1915,6 +1917,12 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1915
1917
  throw e;
1916
1918
  }
1917
1919
  const registry = buildDeferredRegistry(deferred, tools);
1920
+ let activationChain = Promise.resolve();
1921
+ const serializeActivation = (section) => {
1922
+ const p = activationChain.then(section);
1923
+ activationChain = p.then(() => undefined, () => undefined);
1924
+ return p;
1925
+ };
1918
1926
  const directCallFor = (name) => {
1919
1927
  if (spec.deferSelfResolve === false)
1920
1928
  return undefined;
@@ -1930,7 +1938,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1930
1938
  };
1931
1939
  },
1932
1940
  ...(executionMode !== undefined ? { executionMode } : {}),
1933
- activate: async () => {
1941
+ activate: async () => serializeActivation(async () => {
1934
1942
  if (activeTools.has(name))
1935
1943
  return undefined;
1936
1944
  activeTools.add(name);
@@ -1942,7 +1950,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
1942
1950
  throw e;
1943
1951
  }
1944
1952
  return listingRideRef.current?.([name]);
1945
- },
1953
+ }),
1946
1954
  };
1947
1955
  };
1948
1956
  const placeholders = new Map([...registry.values()].map((i) => [i.name, createPlaceholderTool(i, directCallFor(i.name))]));
@@ -2016,6 +2024,7 @@ export async function prepareTask(spec, deps, sessions, resume, internals, runne
2016
2024
  listingRide: (newly) => listingRideRef.current?.(newly),
2017
2025
  mountedNames: callableToolNames,
2018
2026
  directCallEnabled: spec.deferSelfResolve !== false,
2027
+ serializeActivation,
2019
2028
  });
2020
2029
  harnessTools = buildToolList(activeTools);
2021
2030
  }
@@ -222,6 +222,7 @@ function resumeContinuation(resume) {
222
222
  }
223
223
  function makeTurnBoundary(prepared, stats, rs, deps) {
224
224
  const { spec, queue, manualCompactRef, todoToolMounted, taskToolsMounted, turnToolSpan, walltimeDeadlineMs, walltimeMonotonicDeadline, nudgeSchedule, buildFinalizeText, timeout, ident, postToolBatchHook, batchArgs, compactionBrain, withinTaskCompaction, compactionBreaker, windowSafetyOptions, rapidRefill, drainManualCompact, runnerHooks } = deps;
225
+ let finalVerifySeenAtLastBoundary = 0;
225
226
  const onTurnBoundary = async (event) => {
226
227
  if (prepared.callCapRef && turnToolSpan.startMin !== undefined && turnToolSpan.endMax !== undefined) {
227
228
  recordToolCycleSample(prepared.callCapRef.state, turnToolSpan.endMax - turnToolSpan.startMin);
@@ -499,11 +500,13 @@ function makeTurnBoundary(prepared, stats, rs, deps) {
499
500
  }
500
501
  }
501
502
  let batchContextBlock;
503
+ const finalVerifyInjectedThisTurn = rs.counters.finalVerifyInjections !== finalVerifySeenAtLastBoundary;
504
+ finalVerifySeenAtLastBoundary = rs.counters.finalVerifyInjections;
502
505
  if (postToolBatchHook !== undefined && rs.turn.toolBatch.length > 0) {
503
506
  const batch = rs.turn.toolBatch;
504
507
  rs.turn.toolBatch = [];
505
508
  batchArgs?.clear();
506
- if (!boundarySteered && !rs.counters.finalizeInjected && rs.counters.finalVerifyInjections === 0 && !prepared.abortController.signal.aborted) {
509
+ if (!boundarySteered && !rs.counters.finalizeInjected && !finalVerifyInjectedThisTurn && !prepared.abortController.signal.aborted) {
507
510
  try {
508
511
  const r = await postToolBatchHook(batch);
509
512
  if (r?.additionalContext) {
@@ -1662,6 +1665,9 @@ export class Runner {
1662
1665
  const gateToolName = "toolName" in resume.cp.gate ? resume.cp.gate.toolName : undefined;
1663
1666
  prepared.humanReviewRef.gates.push({ kind: resume.cp.gate.kind, waitMs, ...(decision !== undefined ? { decision } : {}), ...(gateToolName !== undefined ? { toolName: gateToolName } : {}) });
1664
1667
  }
1668
+ if (resume !== undefined && resume.outcome.gate === "plan_review" && resume.outcome.decision === "reject") {
1669
+ prepared.planModeRef.active = true;
1670
+ }
1665
1671
  const rs = createRunState();
1666
1672
  rs.telemetry.cacheFamily = cacheFamilyOf(prepared.model);
1667
1673
  rs.telemetry.pricing = this.deps.pricing?.[prepared.model.id] ?? modelCostToPricing(prepared.model.cost);
@@ -2531,13 +2537,26 @@ export class Runner {
2531
2537
  let promptBlocked = false;
2532
2538
  const userPromptSubmit = (spec.hooks ?? this.deps.hooks)?.userPromptSubmit;
2533
2539
  if (userPromptSubmit) {
2534
- const decision = await userPromptSubmit(spec.objective);
2535
- if (decision?.block) {
2536
- prepared.blockedRef.reason = formatHookFeedback(decision.block);
2537
- promptBlocked = true;
2540
+ try {
2541
+ const decision = await userPromptSubmit(spec.objective);
2542
+ if (decision?.block) {
2543
+ prepared.blockedRef.reason = formatHookFeedback(decision.block);
2544
+ promptBlocked = true;
2545
+ }
2546
+ else if (decision?.additionalContext) {
2547
+ effectiveObjective = `${formatHookFeedback(decision.additionalContext)}\n\n${spec.objective}`;
2548
+ }
2538
2549
  }
2539
- else if (decision?.additionalContext) {
2540
- effectiveObjective = `${formatHookFeedback(decision.additionalContext)}\n\n${spec.objective}`;
2550
+ catch (hookErr) {
2551
+ const err = hookErr instanceof Error ? hookErr : new Error(String(hookErr));
2552
+ try {
2553
+ this.deps.onError?.(err, { phase: "hook", sessionId: prepared.sessionId });
2554
+ }
2555
+ catch {
2556
+ }
2557
+ prepared.blockedRef.reason = formatHookFeedback(`the deployment's userPromptSubmit hook crashed while screening this prompt (${err.message}); ` +
2558
+ `the prompt was NOT submitted (fail-closed)`);
2559
+ promptBlocked = true;
2541
2560
  }
2542
2561
  }
2543
2562
  const firstFrames = [];
@@ -3102,7 +3121,7 @@ export class Runner {
3102
3121
  if (!r.ok) {
3103
3122
  const tooLarge = r.error.code === "too_large";
3104
3123
  const refusedAt = Date.now();
3105
- if (tooLarge)
3124
+ if (tooLarge && !this.snapshotTooLargeRoots.has(root))
3106
3125
  this.snapshotTooLargeRoots.set(root, { refusedAt, skipAnnounced: false });
3107
3126
  try {
3108
3127
  this.deps.onError?.(new Error(`rewind-files snapshot failed (${r.error.code}): ${r.error.message}` +
@@ -57,4 +57,5 @@ export declare function createToolSearchTool(opts: {
57
57
  listingRide?: (newlyActivated: readonly string[]) => string | undefined;
58
58
  mountedNames?: () => ReadonlySet<string>;
59
59
  directCallEnabled?: boolean;
60
+ serializeActivation?: <T>(section: () => Promise<T>) => Promise<T>;
60
61
  }): AgentTool;
@@ -172,11 +172,22 @@ export function resolveToolSearch(args, registry) {
172
172
  export function extractDiscoveredToolNames(messages, registry) {
173
173
  const names = new Set();
174
174
  const pendingDirect = new Map();
175
+ const pendingSearch = new Map();
175
176
  for (const m of messages) {
176
177
  if (m.role === "toolResult") {
177
178
  const name = pendingDirect.get(m.toolCallId);
178
- if (name !== undefined && m.isError !== true)
179
- names.add(name);
179
+ if (name !== undefined) {
180
+ pendingDirect.delete(m.toolCallId);
181
+ if (m.isError !== true)
182
+ names.add(name);
183
+ }
184
+ const searched = pendingSearch.get(m.toolCallId);
185
+ if (searched !== undefined) {
186
+ pendingSearch.delete(m.toolCallId);
187
+ if (m.isError !== true)
188
+ for (const n of searched)
189
+ names.add(n);
190
+ }
180
191
  continue;
181
192
  }
182
193
  if (m.role !== "assistant")
@@ -185,15 +196,18 @@ export function extractDiscoveredToolNames(messages, registry) {
185
196
  if (part.type !== "toolCall")
186
197
  continue;
187
198
  if (part.name === TOOL_SEARCH_NAME) {
188
- for (const n of resolveToolSearch(part.arguments, registry)) {
189
- names.add(n);
190
- }
199
+ const prior = pendingSearch.get(part.id);
200
+ const resolved = resolveToolSearch(part.arguments, registry);
201
+ pendingSearch.set(part.id, prior === undefined ? resolved : [...prior, ...resolved]);
191
202
  }
192
203
  else if (registry.has(part.name)) {
193
204
  pendingDirect.set(part.id, part.name);
194
205
  }
195
206
  }
196
207
  }
208
+ for (const list of pendingSearch.values())
209
+ for (const n of list)
210
+ names.add(n);
197
211
  return [...names];
198
212
  }
199
213
  export function createToolSearchTool(opts) {
@@ -209,6 +223,12 @@ export function createToolSearchTool(opts) {
209
223
  "first. When any instruction, reminder, or another tool's description names a deferred tool, activate it " +
210
224
  'with query "select:<name>" before calling it. ';
211
225
  let activationChain = Promise.resolve();
226
+ const serializeActivation = opts.serializeActivation ??
227
+ ((section) => {
228
+ const p = activationChain.then(section);
229
+ activationChain = p.then(() => undefined, () => undefined);
230
+ return p;
231
+ });
212
232
  return defineTool({
213
233
  name: TOOL_SEARCH_NAME,
214
234
  contract: { contractId: "core.tool_search@1", implementationRevision: "1" },
@@ -259,7 +279,7 @@ export function createToolSearchTool(opts) {
259
279
  },
260
280
  };
261
281
  }
262
- const section = activationChain.then(async () => {
282
+ const section = serializeActivation(async () => {
263
283
  const newly = matched.filter((n) => !active.has(n));
264
284
  if (newly.length > 0) {
265
285
  for (const n of newly)
@@ -275,7 +295,6 @@ export function createToolSearchTool(opts) {
275
295
  }
276
296
  return { newly, ride: newly.length > 0 ? listingRide?.(newly) : undefined };
277
297
  });
278
- activationChain = section.then(() => undefined, () => undefined);
279
298
  const { newly, ride } = await section;
280
299
  const lines = matched.map((n) => {
281
300
  const info = registry.get(n);
@@ -114,6 +114,11 @@ export class TtlSessionStore {
114
114
  }
115
115
  const forked = await this.repo.fork(await source.getMetadata(), forkParams);
116
116
  const meta = await forked.getMetadata();
117
+ const inherited = this.owners.has(sourceId) ? this.owners.get(sourceId) : undefined;
118
+ const forkOwner = owner ?? inherited;
119
+ if (forkOwner !== undefined && !this.owners.has(meta.id)) {
120
+ this.owners.set(meta.id, forkOwner);
121
+ }
117
122
  this.entries.set(meta.id, {
118
123
  session: forked,
119
124
  lastActiveAt: Date.now(),
@@ -149,8 +154,13 @@ export class TtlSessionStore {
149
154
  return this.owners.has(sessionId) ? this.owners.get(sessionId) : undefined;
150
155
  }
151
156
  async register(sessionId, owner) {
152
- if (!this.owners.has(sessionId))
153
- this.owners.set(sessionId, owner);
157
+ if (this.owners.has(sessionId)) {
158
+ return;
159
+ }
160
+ if (owner !== null && typeof owner !== "string") {
161
+ throw new TypeError(`SessionStore.register: owner must be a string (owned) or null (anonymous), got ${owner === undefined ? "undefined" : typeof owner}`);
162
+ }
163
+ this.owners.set(sessionId, owner);
154
164
  }
155
165
  sweep(now = Date.now()) {
156
166
  for (const [id, e] of this.entries) {
@@ -160,8 +170,9 @@ export class TtlSessionStore {
160
170
  if (now - e.lastActiveAt > this.defaultTtlMs) {
161
171
  this.entries.delete(id);
162
172
  if (this.evictPolicy === "delete") {
163
- void this.repo.delete({ id, createdAt: "" });
164
- this.owners.delete(id);
173
+ void this.repo.delete({ id, createdAt: "" }).then(() => {
174
+ this.owners.delete(id);
175
+ }, () => { });
165
176
  }
166
177
  }
167
178
  }
@@ -0,0 +1,8 @@
1
+ export interface SkillToolEntry {
2
+ readonly raw: string;
3
+ readonly name: string;
4
+ readonly specifier?: string;
5
+ }
6
+ export declare function parseSkillToolEntry(entry: string): SkillToolEntry;
7
+ export declare function isSkillSpecifierEnforced(canonicalName: string): boolean;
8
+ export declare function skillSpecifierRejection(entry: SkillToolEntry, args: unknown): string | undefined;
@@ -0,0 +1,58 @@
1
+ import { parsePermissionRule, wildcardMatch } from "./permission-rules.js";
2
+ import { canonicalToolName } from "./tool-name-aliases.js";
3
+ import { COARSE_SHELL_TOOLS } from "./tool-policy.js";
4
+ import { parseLeadingCommandName } from "../tools/fs/bash-readonly-classifier.js";
5
+ const SPECIFIER_ENFORCED_TOOLS = new Set(COARSE_SHELL_TOOLS.map(canonicalToolName));
6
+ export function parseSkillToolEntry(entry) {
7
+ const parsed = parsePermissionRule(entry);
8
+ const name = canonicalToolName(parsed.toolName);
9
+ return parsed.ruleContent === undefined ? { raw: entry, name } : { raw: entry, name, specifier: parsed.ruleContent };
10
+ }
11
+ export function isSkillSpecifierEnforced(canonicalName) {
12
+ return SPECIFIER_ENFORCED_TOOLS.has(canonicalName);
13
+ }
14
+ function normalizeSpacing(s) {
15
+ return s.trim().replace(/[ \t]+/g, " ");
16
+ }
17
+ function hasUnescapedStar(content) {
18
+ for (let i = 0; i < content.length; i++) {
19
+ if (content[i] !== "*")
20
+ continue;
21
+ let backslashes = 0;
22
+ for (let j = i - 1; j >= 0 && content[j] === "\\"; j--)
23
+ backslashes++;
24
+ if (backslashes % 2 === 0)
25
+ return true;
26
+ }
27
+ return false;
28
+ }
29
+ function specifierMatchesCommand(specifier, command) {
30
+ const spec = normalizeSpacing(specifier);
31
+ const prefix = /^(.+):\*$/.exec(spec)?.[1];
32
+ if (prefix !== undefined) {
33
+ return command === prefix || command.startsWith(prefix + " ");
34
+ }
35
+ if (hasUnescapedStar(spec))
36
+ return wildcardMatch(spec, command);
37
+ return command === spec;
38
+ }
39
+ export function skillSpecifierRejection(entry, args) {
40
+ const { specifier } = entry;
41
+ if (specifier === undefined)
42
+ return undefined;
43
+ if (!isSkillSpecifierEnforced(entry.name)) {
44
+ return `entry "${entry.raw}" narrows a tool whose calls this gate cannot match against a command pattern — only the shell tools (${[...SPECIFIER_ENFORCED_TOOLS].join(", ")}) carry a command string, so the entry admits nothing`;
45
+ }
46
+ const command = args?.command;
47
+ if (typeof command !== "string") {
48
+ return `entry "${entry.raw}" requires a command string to match against, and this call has none`;
49
+ }
50
+ const parsedCommand = parseLeadingCommandName(command);
51
+ if ("reject" in parsedCommand) {
52
+ return `entry "${entry.raw}" only admits a single simple command (${parsedCommand.reject})`;
53
+ }
54
+ if (!specifierMatchesCommand(specifier, normalizeSpacing(command))) {
55
+ return `command "${command.trim()}" is not admitted by entry "${entry.raw}"`;
56
+ }
57
+ return undefined;
58
+ }
@@ -1,5 +1,5 @@
1
1
  import type { SkillSpec } from "./types.js";
2
- export type SkillsDirectoryWarningCode = "no_skill_file" | "no_frontmatter" | "missing_name" | "invalid_name" | "name_mismatch" | "missing_description" | "description_too_long" | "allowed_tool_not_mounted" | "disallowed_tools_unenforced" | "attachment_skipped" | "read_failed";
2
+ export type SkillsDirectoryWarningCode = "no_skill_file" | "no_frontmatter" | "missing_name" | "invalid_name" | "name_mismatch" | "missing_description" | "allowed_tool_pattern_unsupported" | "description_too_long" | "allowed_tool_not_mounted" | "disallowed_tools_unenforced" | "attachment_skipped" | "read_failed";
3
3
  export interface SkillsDirectoryWarning {
4
4
  code: SkillsDirectoryWarningCode;
5
5
  skill: string;
@@ -1,6 +1,7 @@
1
1
  import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
2
2
  import { isAbsolute, join, relative } from "node:path";
3
3
  import { canonicalToolName } from "./tool-name-aliases.js";
4
+ import { isSkillSpecifierEnforced, parseSkillToolEntry } from "./skill-tool-specifier.js";
4
5
  const SKILL_FILE = "SKILL.md";
5
6
  const RESOURCE_DIRS = ["assets", "references", "scripts"];
6
7
  const NAME_MAX_CHARS = 64;
@@ -316,12 +317,23 @@ function manifestFromAllowedTools(declared, disallowed, skillName, deployedTools
316
317
  const names = [];
317
318
  const seen = new Set();
318
319
  for (const n of splitToolNames(declared)) {
319
- const key = canonicalToolName(n);
320
+ const entry = parseSkillToolEntry(n);
321
+ const key = entry.specifier === undefined ? entry.name : `${entry.name}(${entry.specifier})`;
320
322
  if (seen.has(key))
321
323
  continue;
322
324
  seen.add(key);
323
325
  names.push(n);
324
326
  }
327
+ for (const n of names) {
328
+ const entry = parseSkillToolEntry(n);
329
+ if (entry.specifier !== undefined && !isSkillSpecifierEnforced(entry.name)) {
330
+ warn({
331
+ code: "allowed_tool_pattern_unsupported",
332
+ skill: skillName,
333
+ detail: `allowed-tools entry "${n}" narrows a tool whose calls cannot be matched against a command pattern — only shell tools carry one, so the entry is not enforced and the tool is DISABLED inside this skill frame. Use the bare tool name to allow it fully.`,
334
+ });
335
+ }
336
+ }
325
337
  if (names.length === 0)
326
338
  return undefined;
327
339
  if (names.some((n) => n === ALL_TOOLS_WILDCARD))
@@ -329,9 +341,9 @@ function manifestFromAllowedTools(declared, disallowed, skillName, deployedTools
329
341
  let allowTools = names;
330
342
  if (deployedTools !== undefined) {
331
343
  const mounted = new Set(deployedTools.map(canonicalToolName));
332
- allowTools = names.filter((n) => mounted.has(canonicalToolName(n)));
344
+ allowTools = names.filter((n) => mounted.has(parseSkillToolEntry(n).name));
333
345
  for (const n of names) {
334
- if (!mounted.has(canonicalToolName(n))) {
346
+ if (!mounted.has(parseSkillToolEntry(n).name)) {
335
347
  warn({
336
348
  code: "allowed_tool_not_mounted",
337
349
  skill: skillName,
@@ -342,7 +354,7 @@ function manifestFromAllowedTools(declared, disallowed, skillName, deployedTools
342
354
  }
343
355
  if (disallowed.length > 0) {
344
356
  const denied = new Set(disallowed.map(canonicalToolName));
345
- allowTools = allowTools.filter((n) => !denied.has(canonicalToolName(n)));
357
+ allowTools = allowTools.filter((n) => !denied.has(parseSkillToolEntry(n).name));
346
358
  }
347
359
  return { allowTools, lineageId: `skill:${skillName}` };
348
360
  }
@@ -1,6 +1,6 @@
1
1
  import type { TSchema } from "typebox";
2
2
  import type { AgentTool, ThinkingLevel } from "../internal/harness.js";
3
- import type { DocumentContent, ImageContent, Model, ResilienceOptions, StreamFn, TextContent } from "../internal/llm.js";
3
+ import type { CompleteSimpleFn, DocumentContent, ImageContent, Model, ResilienceOptions, StreamFn, TextContent } from "../internal/llm.js";
4
4
  import type { TaskNotificationPayload } from "./task-notification.js";
5
5
  export type ModelRef = string | Model;
6
6
  export type ModelRole = "default" | "summarize" | "subagent" | "team" | "synthesize" | "advisor" | "verifier" | "classifier";
@@ -13,10 +13,7 @@ export type RoleSpec = ModelRef | {
13
13
  export type ModelRoles = Partial<Record<ModelRole, RoleSpec>>;
14
14
  export interface Brain {
15
15
  stream: StreamFn;
16
- complete?: (model: Model, context: {
17
- systemPrompt?: string;
18
- messages: unknown[];
19
- }, options?: unknown) => Promise<unknown>;
16
+ complete?: CompleteSimpleFn;
20
17
  }
21
18
  export type ToolEffect = "read" | "write" | "idempotent";
22
19
  export interface ToolSpec<TParams extends TSchema = TSchema> {
@@ -13,7 +13,6 @@ export async function withRetry(op, policy, opts = {}) {
13
13
  opts.signal?.removeEventListener("abort", onAbort);
14
14
  resolve();
15
15
  }, ms);
16
- timer.unref?.();
17
16
  opts.signal?.addEventListener("abort", onAbort, { once: true });
18
17
  }));
19
18
  let last = await op(1);
package/dist/index.d.ts CHANGED
@@ -188,7 +188,7 @@ export { createDegradingBrain, readDegradation, DEGRADED_DIAGNOSTIC_TYPE, type D
188
188
  export { retryBackoffMs, parseRetryAfter } from "./brain/retry.js";
189
189
  export { type BrainTimeoutConfig } from "./brain/timeout.js";
190
190
  export { createAssistantMessageEventStream } from "./internal/llm.js";
191
- export type { AssistantMessage, AssistantMessageEvent, Context, DocumentContent, ImageContent, Message, StopReason, StreamFn, TextContent, ThinkingContent, ToolCall, ToolResultMessage, Usage, UserMessage, } from "./internal/llm.js";
191
+ export type { AssistantMessage, AssistantMessageEvent, CompleteSimpleFn, Context, DocumentContent, ImageContent, Message, StopReason, StreamFn, TextContent, ThinkingContent, ToolCall, ToolResultMessage, Usage, UserMessage, } from "./internal/llm.js";
192
192
  export type { AgentDefinition, BeforeWriteHook, BeforeWriteRequest, BeforeWriteResult, HandsBandOptions, Brain, BrainStatus, BrainStatusPhase, ImageInput, McpElicitRequest, McpElicitResponse, McpServerSpec, OnElicit, Model, ModelRef, ProjectMemoryLoad, RunnerDeps, RuntimeCaps, BackgroundChildEvent, SkillManifest, SkillSpec, TaskEvent, TaskEventIdentity, ToolActivity, TaskResult, RemoteEnvFailureNote, TaskSpec, TaskStatus, TaskStream, CompactOutcome, ThinkingLevel, ToolExecuteContext, ToolReturn, ToolSpec, ToolEffect, WorkflowGovernanceBaseline, } from "./core/types.js";
193
193
  export { Type } from "typebox";
194
194
  export type { TSchema, Static } from "typebox";
@@ -1,2 +1,2 @@
1
1
  export { createAssistantMessageEventStream, stripEngineMetadata } from "../engine/llm/index.js";
2
- export type { AnthropicMessagesCompat, AssistantMessage, AssistantMessageDiagnostic, AssistantMessageEvent, Context, DocumentContent, ImageContent, Message, Model, ResilienceOptions, SimpleStreamOptions, StallTimeouts, StopReason, StreamFn, TextContent, ThinkingContent, Tool, ToolCall, ToolResultMessage, Usage, UserMessage, } from "../engine/llm/index.js";
2
+ export type { AnthropicMessagesCompat, AssistantMessage, AssistantMessageDiagnostic, AssistantMessageEvent, CompleteSimpleFn, Context, DocumentContent, ImageContent, Message, Model, ResilienceOptions, SimpleStreamOptions, StallTimeouts, StopReason, StreamFn, TextContent, ThinkingContent, Tool, ToolCall, ToolResultMessage, Usage, UserMessage, } from "../engine/llm/index.js";
@@ -480,13 +480,15 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
480
480
  if (row !== undefined)
481
481
  row.sessionId = sessionId;
482
482
  };
483
- const bceTick = (callKey, e) => {
483
+ const bceTick = (callKey, e, fromSessionId) => {
484
484
  if (!bceSink)
485
485
  return;
486
486
  const id = waIdOf(callKey);
487
487
  const row = bceLive.get(id);
488
488
  if (row === undefined)
489
489
  return;
490
+ if (fromSessionId !== undefined && row.sessionId !== undefined && fromSessionId !== row.sessionId)
491
+ return;
490
492
  bceEmit({
491
493
  kind: "tick",
492
494
  taskId: id,
@@ -507,8 +509,10 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
507
509
  if (!bceSink)
508
510
  return;
509
511
  const id = waIdOf(callKey);
512
+ const row = bceLive.get(id);
510
513
  if (!bceLive.delete(id))
511
514
  return;
515
+ const coord = sessionId ?? row?.sessionId;
512
516
  bceEmit({
513
517
  kind: "terminal",
514
518
  taskId: id,
@@ -516,7 +520,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
516
520
  owner: runId,
517
521
  workflowRunId: runId,
518
522
  ...(scope !== undefined ? { scope } : {}),
519
- ...(sessionId !== undefined ? { sessionId, transcriptId: sessionId } : {}),
523
+ ...(coord !== undefined ? { sessionId: coord, transcriptId: coord } : {}),
520
524
  status,
521
525
  summary: boundedRedactedSummary(summary, 300),
522
526
  ...(stats !== undefined ? { usage: { tokens: stats.tokens, turns: stats.turns, ...(stats.costMicroUsd !== undefined ? { costMicroUsd: stats.costMicroUsd } : {}) } } : {}),
@@ -873,7 +877,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
873
877
  ? {
874
878
  onForwardEvent: (e) => {
875
879
  if (e.type === "task_progress")
876
- bceTick(callKey, e);
880
+ bceTick(callKey, e, attemptSessionId);
877
881
  try {
878
882
  baseInternals.onForwardEvent?.(e);
879
883
  }
@@ -1181,7 +1185,7 @@ export function startWorkflow(runner, fn, opts = {}, internals) {
1181
1185
  ? {
1182
1186
  onForwardEvent: (e) => {
1183
1187
  if (e.type === "task_progress")
1184
- bceTick(callKey, e);
1188
+ bceTick(callKey, e, childSessionId);
1185
1189
  try {
1186
1190
  enrichedForwardS?.(e);
1187
1191
  }
@@ -107,10 +107,42 @@ function hasUnquotedExpansionMetachar(rawToken) {
107
107
  }
108
108
  return false;
109
109
  }
110
+ function longOptionNameOf(tok) {
111
+ if (!tok.startsWith("--") || tok.length === 2)
112
+ return undefined;
113
+ const body = tok.slice(2);
114
+ const eq = body.indexOf("=");
115
+ const name = eq >= 0 ? body.slice(0, eq) : body;
116
+ return name.length > 0 ? name : undefined;
117
+ }
118
+ function isLongOptionAbbrevOf(name, full) {
119
+ return full.startsWith(name);
120
+ }
110
121
  function isGrepPatternFlagToken(tok) {
111
- if (tok.startsWith("--"))
112
- return tok.startsWith("--regexp") || tok.startsWith("--file");
113
- return /^-[A-Za-z]*[ef]/.test(tok);
122
+ if (tok.startsWith("--")) {
123
+ const long = longOptionNameOf(tok);
124
+ return long !== undefined && (isLongOptionAbbrevOf(long, "regexp") || isLongOptionAbbrevOf(long, "file"));
125
+ }
126
+ return /^-[A-Za-z0-9]*[ef]/.test(tok);
127
+ }
128
+ function isGrepPatternPayloadLongOption(tok) {
129
+ if (!tok.includes("="))
130
+ return false;
131
+ const name = longOptionNameOf(tok);
132
+ return name !== undefined && isLongOptionAbbrevOf(name, "regexp");
133
+ }
134
+ function isCutDelimiterPayloadLongOption(tok) {
135
+ if (!tok.includes("="))
136
+ return false;
137
+ const name = longOptionNameOf(tok);
138
+ return name !== undefined && (isLongOptionAbbrevOf(name, "delimiter") || isLongOptionAbbrevOf(name, "output-delimiter"));
139
+ }
140
+ function isGrepFileStdinLongOption(tok) {
141
+ const eq = tok.indexOf("=");
142
+ if (eq < 0 || tok.slice(eq + 1) !== "-")
143
+ return false;
144
+ const name = longOptionNameOf(tok);
145
+ return name !== undefined && isLongOptionAbbrevOf(name, "file");
114
146
  }
115
147
  function grepClusterValueOwner(tok) {
116
148
  if (!/^-[A-Za-z]/.test(tok) || tok.startsWith("--"))
@@ -176,9 +208,9 @@ function collectSegmentBoundaryFindings(tokens, boundary) {
176
208
  k++;
177
209
  continue;
178
210
  }
179
- if (name === "cut" && (/^-d./.test(t) || /^--(output-)?delimiter=/.test(t)))
211
+ if (name === "cut" && (/^-d./.test(t) || isCutDelimiterPayloadLongOption(t)))
180
212
  continue;
181
- if (name === "grep" && (grepClusterValueOwner(t) === "e" || /^--regexp=/.test(t)))
213
+ if (name === "grep" && (grepClusterValueOwner(t) === "e" || isGrepPatternPayloadLongOption(t)))
182
214
  continue;
183
215
  if (t.startsWith("-") && t !== "-") {
184
216
  for (const payload of attachedOptionPayloads(t)) {
@@ -312,7 +344,7 @@ export function classifyCompoundReadonlyDetailed(command, allow, boundary) {
312
344
  k++;
313
345
  continue;
314
346
  }
315
- if (name === "grep" && (t === "-f-" || t === "--file=-")) {
347
+ if (name === "grep" && (t === "-f-" || isGrepFileStdinLongOption(t))) {
316
348
  hasStdinDash = true;
317
349
  break;
318
350
  }
@@ -158,6 +158,12 @@ export function createSchedulerTools(env, ctx) {
158
158
  return [];
159
159
  const sched = env;
160
160
  const schedCtx = toSchedulerContext(ctx);
161
+ let cronCreateChain = Promise.resolve();
162
+ const serializedCronCreateOp = (fn) => {
163
+ const next = cronCreateChain.then(fn, fn);
164
+ cronCreateChain = next.then(() => undefined, () => undefined);
165
+ return next;
166
+ };
161
167
  const cronCreateDescription = `Schedule a prompt to be enqueued at a future time. Use for both recurring schedules and one-shot reminders. The task runs UNATTENDED when it fires, so write a fully self-contained \`prompt\` (it will not see this conversation). Use CronList to see what you've scheduled, CronDelete to remove one.
162
168
 
163
169
  ## One-shot tasks (schedule kind "delay" or "at")
@@ -210,7 +216,7 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
210
216
  label: Type.Optional(Type.String({ description: "Short label (also a dedup key with the schedule)." })),
211
217
  }),
212
218
  effect: "write",
213
- execute: async (args) => {
219
+ execute: async (args) => serializedCronCreateOp(async () => {
214
220
  const a = args;
215
221
  if (a.schedule !== undefined && a.cron !== undefined) {
216
222
  return errorResult("Error (CronCreate): pass exactly one of `schedule` (object form) or `cron` (string form), not both.");
@@ -224,7 +230,7 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
224
230
  const when = a.schedule ?? { kind: "cron", expr: a.cron };
225
231
  if (when.kind === "cron") {
226
232
  if (!isValidCronExpr(when.expr)) {
227
- return errorResult(`Error (CronCreate): invalid cron expression "${when.expr}" — only 5/6 space-separated cron fields (digits and * / , -) are allowed.`);
233
+ return errorResult(`Error (CronCreate): invalid cron expression "${when.expr}" — only 5 space-separated cron fields (digits and * / , -) are allowed (a 6th leading seconds field is parsed but always rejected next — this scheduler has no seconds hand).`);
228
234
  }
229
235
  const deepErr = cronScheduleError(when.expr);
230
236
  if (deepErr)
@@ -277,7 +283,7 @@ Only use minute 0 or 30 when the user names that exact time and clearly means it
277
283
  ...(replaced !== undefined ? { replaced } : {}),
278
284
  },
279
285
  };
280
- },
286
+ }),
281
287
  });
282
288
  const cronCancel = defineTool({
283
289
  name: "CronDelete",
@@ -6,7 +6,10 @@ export interface WebFetchConfig {
6
6
  fetchImpl?: typeof fetch;
7
7
  maxBytes?: number;
8
8
  timeoutMs?: number;
9
- summarize?: (content: string, prompt: string, signal?: AbortSignal) => Promise<string>;
9
+ summarize?: (content: string, prompt: string, signal?: AbortSignal) => Promise<string | {
10
+ text: string;
11
+ truncated?: boolean;
12
+ }>;
10
13
  userAgent?: string;
11
14
  }
12
15
  export declare function htmlToText(html: string): string;
@@ -14,7 +17,10 @@ export declare function webFetchToolSpec(config?: WebFetchConfig): ToolSpec;
14
17
  export declare function createWebFetchTool(config?: WebFetchConfig): AgentTool;
15
18
  export declare const WEBFETCH_SUMMARY_MAX_CONTENT = 100000;
16
19
  export declare const WEBFETCH_SUMMARY_GUIDELINES: string;
17
- export declare function createWebFetchSummarizer(brain: Brain, model: Model): (content: string, prompt: string, signal?: AbortSignal) => Promise<string>;
20
+ export declare function createWebFetchSummarizer(brain: Brain, model: Model): (content: string, prompt: string, signal?: AbortSignal) => Promise<string | {
21
+ text: string;
22
+ truncated?: boolean;
23
+ }>;
18
24
  export interface WebSearchConfig {
19
25
  search: (query: string, signal?: AbortSignal, opts?: {
20
26
  allowedDomains?: string[];
package/dist/tools/web.js CHANGED
@@ -495,6 +495,7 @@ export function webFetchToolSpec(config = {}) {
495
495
  }
496
496
  const text = /html/i.test(contentType) || /^\s*</.test(raw) ? htmlToText(raw) : raw;
497
497
  let out = text;
498
+ let summaryTruncated = false;
498
499
  let note;
499
500
  if (prompt && bodyCut) {
500
501
  note =
@@ -503,7 +504,17 @@ export function webFetchToolSpec(config = {}) {
503
504
  }
504
505
  else if (prompt && config.summarize) {
505
506
  try {
506
- out = await config.summarize(text, prompt, ctx.signal);
507
+ const summarized = await config.summarize(text, prompt, ctx.signal);
508
+ if (typeof summarized === "string") {
509
+ out = summarized;
510
+ }
511
+ else {
512
+ out = summarized.text;
513
+ if (summarized.truncated) {
514
+ summaryTruncated = true;
515
+ note = `[note: the summary below is INCOMPLETE — the summarizer hit its output limit before finishing]`;
516
+ }
517
+ }
507
518
  }
508
519
  catch (e) {
509
520
  out = text;
@@ -534,7 +545,7 @@ export function webFetchToolSpec(config = {}) {
534
545
  bytes: bodyBytes ? bodyBytes.length : raw.length,
535
546
  result: out.length > RESULT_PREVIEW_CHARS ? `${out.slice(0, RESULT_PREVIEW_CHARS)}\n…[${out.length - RESULT_PREVIEW_CHARS} chars truncated — full text in the tool output]` : out,
536
547
  durationMs: Date.now() - startedAt,
537
- ...(truncationNote ? { truncated: true } : {}),
548
+ ...(truncationNote || summaryTruncated ? { truncated: true } : {}),
538
549
  ...transferStateDetails,
539
550
  },
540
551
  };
@@ -560,18 +571,17 @@ export function createWebFetchSummarizer(brain, model) {
560
571
  const msg = brain.complete
561
572
  ? await brain.complete(model, context, { signal })
562
573
  : await (await Promise.resolve(brain.stream(model, context, { signal }))).result();
563
- const am = msg;
564
- if (am?.stopReason === "error" || am?.stopReason === "aborted") {
565
- throw new Error(am.errorMessage ?? `summarizer stopped with ${am.stopReason}`);
574
+ if (msg.stopReason === "error" || msg.stopReason === "aborted") {
575
+ throw new Error(msg.errorMessage ?? `summarizer stopped with ${msg.stopReason}`);
566
576
  }
567
- const text = (am?.content ?? [])
568
- .filter((b) => b?.type === "text" && typeof b.text === "string")
577
+ const text = msg.content
578
+ .filter((b) => b.type === "text" && typeof b.text === "string")
569
579
  .map((b) => b.text)
570
580
  .join("\n")
571
581
  .trim();
572
582
  if (!text)
573
583
  throw new Error("summarizer returned no text");
574
- return text;
584
+ return msg.stopReason === "length" || msg.partialFinalized === true ? { text, truncated: true } : text;
575
585
  };
576
586
  }
577
587
  const DEFAULT_SEARCH_TIMEOUT_MS = 30_000;
@@ -801,25 +811,27 @@ export function createWebSearchTool(config) {
801
811
  export function createSearxngSearchBackend(baseUrl, options = {}) {
802
812
  const doFetch = options.fetchImpl ?? globalThis.fetch;
803
813
  const timeoutMs = options.timeoutMs ?? 10_000;
804
- const base = baseUrl.replace(/\/+$/, "");
814
+ const parsedBase = new URL(baseUrl);
815
+ parsedBase.hash = "";
816
+ parsedBase.pathname = `${parsedBase.pathname.replace(/\/+$/, "")}/search`;
805
817
  return async (query, signal, opts) => {
806
818
  const q = opts?.allowedDomains && opts.allowedDomains.length > 0
807
819
  ? `${opts.allowedDomains.map((d) => `site:${d}`).join(" OR ")} ${query}`
808
820
  : query;
809
- const url = new URL(`${base}/search`);
810
- url.searchParams.set("q", q);
811
- url.searchParams.set("format", "json");
821
+ const url = new URL(parsedBase);
812
822
  for (const [k, v] of Object.entries(options.extraParams ?? {}))
813
823
  url.searchParams.set(k, v);
824
+ url.searchParams.set("q", q);
825
+ url.searchParams.set("format", "json");
814
826
  const timeout = AbortSignal.timeout(timeoutMs);
815
827
  const res = await doFetch(url, { signal: signal ? AbortSignal.any([signal, timeout]) : timeout, headers: { accept: "application/json" } });
816
828
  if (!res.ok)
817
- throw new Error(`SearXNG ${res.status} ${res.statusText} from ${base}/search`);
829
+ throw new Error(`SearXNG ${res.status} ${res.statusText} from ${url.origin}${url.pathname}`);
818
830
  const body = (await res.json());
819
831
  if (!Array.isArray(body.results))
820
- throw new Error(`SearXNG returned no results array from ${base}/search — is format=json enabled on this instance?`);
832
+ throw new Error(`SearXNG returned no results array from ${url.origin}${url.pathname} — is format=json enabled on this instance?`);
821
833
  return body.results
822
- .filter((r) => typeof r.url === "string" && r.url !== "")
834
+ .filter((r) => typeof r === "object" && r !== null && typeof r.url === "string" && r.url !== "")
823
835
  .map((r) => ({
824
836
  title: typeof r.title === "string" && r.title !== "" ? r.title : r.url,
825
837
  url: r.url,
@@ -829,14 +841,31 @@ export function createSearxngSearchBackend(baseUrl, options = {}) {
829
841
  }
830
842
  export async function probeSearchBackend(search, options) {
831
843
  const budget = options?.timeoutMs ?? 15_000;
844
+ let timer;
832
845
  try {
833
846
  const results = await Promise.race([
834
847
  search("connectivity probe", AbortSignal.timeout(budget)),
835
- new Promise((_, reject) => setTimeout(() => reject(new Error(`probe timed out after ${budget}ms`)), budget)),
848
+ new Promise((_, reject) => {
849
+ timer = setTimeout(() => reject(new Error(`probe timed out after ${budget}ms`)), budget);
850
+ }),
836
851
  ]);
837
852
  return { ok: true, results: results.length };
838
853
  }
839
854
  catch (e) {
840
- return { ok: false, error: e instanceof Error ? e.message : String(e) };
855
+ let text;
856
+ if (e instanceof Error)
857
+ text = e.message;
858
+ else {
859
+ try {
860
+ text = String(e);
861
+ }
862
+ catch {
863
+ text = Object.prototype.toString.call(e);
864
+ }
865
+ }
866
+ return { ok: false, error: text };
867
+ }
868
+ finally {
869
+ clearTimeout(timer);
841
870
  }
842
871
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sema-agent/core",
3
- "version": "2.12.0",
3
+ "version": "2.13.0",
4
4
  "description": "Stateless, task-oriented AI agent core",
5
5
  "type": "module",
6
6
  "license": "BUSL-1.1",