skyloom 1.18.0 → 1.18.2

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.
@@ -44,7 +44,9 @@ export interface WebHttp {
44
44
  }
45
45
 
46
46
  const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';
47
- const DEFAULT_TIMEOUT = 15000;
47
+ // Per-request timeout. Kept short so a dead/slow provider fails fast and the
48
+ // waterfall moves on quickly rather than burning 15s each across five providers.
49
+ const DEFAULT_TIMEOUT = 8000;
48
50
 
49
51
  /** Default HTTP client backed by axios. */
50
52
  export const defaultHttp: WebHttp = {
@@ -294,40 +296,54 @@ export interface WebSearchOptions {
294
296
  env?: EnvMap; // defaults to process.env
295
297
  http?: WebHttp; // defaults to axios-backed client
296
298
  onProviderError?: (provider: string, error: string) => void;
299
+ /** Total wall-clock budget for the waterfall; stop trying once exceeded. */
300
+ budgetMs?: number;
297
301
  }
298
302
 
299
303
  /**
300
304
  * Run a web search through the provider waterfall. Returns the first provider
301
305
  * that yields results, or a response with an empty result set + the list of
302
- * providers that were tried.
306
+ * providers that were tried. A total time budget caps the waterfall so the tool
307
+ * returns a clear "no results" message instead of being killed by an outer
308
+ * timeout (which would surface as an opaque "Tool execution timeout").
303
309
  */
304
- export async function webSearch(query: string, opts: WebSearchOptions = {}): Promise<SearchResponse & { tried: string[] }> {
310
+ export async function webSearch(query: string, opts: WebSearchOptions = {}): Promise<SearchResponse & { tried: string[]; errors?: number }> {
305
311
  const q = (query || '').trim();
306
312
  if (!q) throw new Error('query is required');
307
313
  const max = Math.max(1, Math.min(20, Math.floor(opts.max ?? 8)));
308
314
  const env = opts.env ?? (process.env as EnvMap);
309
315
  const http = opts.http ?? defaultHttp;
310
316
  const pinned = (opts.engine || env.SKYLOOM_SEARCH_ENGINE || '').trim();
317
+ const budgetMs = opts.budgetMs ?? 22000;
318
+ const start = Date.now();
311
319
 
312
320
  const providers = resolveProviders(env, pinned);
313
321
  const tried: string[] = [];
322
+ let errors = 0;
314
323
  for (const provider of providers) {
324
+ // Out of time: stop the waterfall and return what we have (a clear empty
325
+ // result), rather than letting a slow tail provider blow the tool timeout.
326
+ if (tried.length > 0 && Date.now() - start > budgetMs) break;
315
327
  tried.push(provider.id);
316
328
  try {
317
329
  const res = await provider.run(http, env, q, max);
318
330
  if (res.results.length > 0 || res.answer) return { ...res, tried };
319
331
  } catch (e: any) {
332
+ errors++;
320
333
  opts.onProviderError?.(provider.id, String(e?.message || e));
321
334
  }
322
335
  }
323
- return { provider: 'none', results: [], tried };
336
+ return { provider: 'none', results: [], tried, errors };
324
337
  }
325
338
 
326
339
  /** Format a SearchResponse as compact text for an LLM tool result. */
327
- export function formatSearchResults(res: SearchResponse & { tried?: string[] }): string {
340
+ export function formatSearchResults(res: SearchResponse & { tried?: string[]; errors?: number }): string {
328
341
  if (!res.results.length && !res.answer) {
329
342
  const tried = res.tried?.length ? ` (tried: ${res.tried.join(', ')})` : '';
330
- return `No search results found${tried}. Try a simpler query, or set a search API key (TAVILY_API_KEY / BRAVE_API_KEY / SERPER_API_KEY) for more reliable results.`;
343
+ const note = res.errors && res.errors > 0
344
+ ? ' Every provider errored or timed out — likely no/blocked network connectivity or rate limiting in this environment.'
345
+ : '';
346
+ return `No search results found${tried}.${note} Try a simpler query, or set a search API key (TAVILY_API_KEY / BRAVE_API_KEY / SERPER_API_KEY) for more reliable results.`;
331
347
  }
332
348
  const parts: string[] = [];
333
349
  if (res.answer) parts.push(`Answer: ${res.answer}\n`);
@@ -0,0 +1,44 @@
1
+ import { describe, it, expect, afterEach } from "vitest";
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { getLogger, setLogSink, setLogFile, silenceLogs } from "../src/core/logger";
6
+
7
+ afterEach(() => { silenceLogs(); }); // never leak to the terminal between tests
8
+
9
+ describe("logger · sink routing", () => {
10
+ it("routes log lines to a custom sink instead of stderr", () => {
11
+ const lines: string[] = [];
12
+ setLogSink((l) => lines.push(l));
13
+ getLogger("test-sink").warn("hello_world", { a: 1 });
14
+ expect(lines.length).toBe(1);
15
+ const entry = JSON.parse(lines[0]);
16
+ expect(entry.msg).toBe("hello_world");
17
+ expect(entry.level).toBe("warn");
18
+ expect(entry.a).toBe(1);
19
+ });
20
+
21
+ it("silenceLogs discards output (keeps a TUI clean)", () => {
22
+ let count = 0;
23
+ setLogSink(() => { count++; });
24
+ silenceLogs();
25
+ getLogger("test-silence").error("should_be_dropped");
26
+ expect(count).toBe(0);
27
+ });
28
+
29
+ it("setLogFile appends log lines to a file, not the terminal", () => {
30
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sky-log-"));
31
+ const file = path.join(dir, "sky.log");
32
+ try {
33
+ const resolved = setLogFile(file);
34
+ expect(resolved).toBe(file);
35
+ getLogger("test-file").warn("to_file", { n: 7 });
36
+ const content = fs.readFileSync(file, "utf8");
37
+ expect(content).toContain("to_file");
38
+ expect(content).toContain('"n":7');
39
+ } finally {
40
+ silenceLogs();
41
+ try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
42
+ }
43
+ });
44
+ });
@@ -0,0 +1,78 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { styleLine, newRenderState, styleBlock, styleInline } from "../src/cli/md_render";
3
+
4
+ describe("md_render · styleInline", () => {
5
+ it("renders bold", () => {
6
+ const out = styleInline("hello **world** ok");
7
+ expect(out).not.toContain("**");
8
+ expect(out).toContain("world");
9
+ });
10
+ it("renders inline code", () => {
11
+ const out = styleInline("use `foo.bar()` here");
12
+ // inline code should preserve content, not contain backticks
13
+ expect(out).not.toContain("`");
14
+ expect(out).toContain("foo.bar()");
15
+ });
16
+ it("does not re-style content inside backticks", () => {
17
+ const out = styleInline("` **not bold** `");
18
+ expect(out).not.toContain("**");
19
+ expect(out).toContain("not bold");
20
+ });
21
+ });
22
+
23
+ describe("md_render · styleLine (block+inline)", () => {
24
+ it("renders H2 heading", () => {
25
+ const s = newRenderState();
26
+ const out = styleLine("## 设计概要", s);
27
+ expect(out).not.toContain("##");
28
+ expect(out).toContain("设计概要");
29
+ });
30
+ it("renders HR", () => {
31
+ const s = newRenderState();
32
+ const out = styleLine("---", s);
33
+ expect(out).not.toContain("---");
34
+ expect(out.length).toBeGreaterThan(2);
35
+ });
36
+ it("renders unordered list bullets", () => {
37
+ const s = newRenderState();
38
+ const out = styleLine("- 性能 · 每秒", s);
39
+ expect(out).not.toStrictEqual(expect.stringContaining("- ")); // raw hyphen gone
40
+ expect(out).toContain("性能");
41
+ });
42
+ it("renders bold+italic in content lines", () => {
43
+ const s = newRenderState();
44
+ const out = styleLine("**粗体** 和 *斜体* 文字", s);
45
+ expect(out).not.toContain("**");
46
+ expect(out).not.toContain("*");
47
+ expect(out).toContain("粗体");
48
+ expect(out).toContain("斜体");
49
+ });
50
+ });
51
+
52
+ describe("md_render · code fences", () => {
53
+ it("tracks fence state and renders code blocks raw", () => {
54
+ const s = newRenderState();
55
+ expect(styleLine("```ts", s)).toContain("```");
56
+ expect(s.inCodeFence).toBe(true);
57
+ expect(styleLine("const x = 1;", s)).toContain("const x = 1;");
58
+ expect(styleLine("```", s)).toContain("```");
59
+ expect(s.inCodeFence).toBe(false);
60
+ });
61
+ it("does not style bold inside a code fence", () => {
62
+ const s = newRenderState();
63
+ styleLine("```", s);
64
+ const out = styleLine("**not bold**", s);
65
+ expect(out).toContain("**not bold**");
66
+ });
67
+ });
68
+
69
+ describe("md_render · styleBlock", () => {
70
+ it("renders a multi-line markdown block", () => {
71
+ const out = styleBlock("## H2\n- bullet **bold**\n\n```\ncode\n```\nnormal.");
72
+ expect(out).not.toMatch(/^\s*##/);
73
+ expect(out).toContain("H2");
74
+ expect(out).toContain("bold");
75
+ expect(out).toContain("code");
76
+ expect(out).not.toContain("**bold**");
77
+ });
78
+ });
@@ -140,6 +140,30 @@ describe("websearch · waterfall", () => {
140
140
  expect(formatSearchResults(res)).toContain("No search results");
141
141
  });
142
142
 
143
+ it("flags that all providers errored/timed out for a clear message", async () => {
144
+ const http = stubHttp([
145
+ { match: "s.jina.ai", throws: "timeout" },
146
+ { match: "duckduckgo", throws: "timeout" }, { match: "bing", throws: "timeout" },
147
+ { match: "baidu", throws: "timeout" }, { match: "sogou", throws: "timeout" },
148
+ ]);
149
+ const res = await webSearch("q", { env: {}, http });
150
+ expect(res.errors).toBeGreaterThan(0);
151
+ expect(formatSearchResults(res)).toMatch(/errored or timed out/);
152
+ });
153
+
154
+ it("stops the waterfall once the time budget is exceeded", async () => {
155
+ // jina resolves (empty) but slowly; the budget then cuts off the scrapers.
156
+ const http = {
157
+ calls: [] as string[],
158
+ async getJson(url: string) { this.calls.push(url); await new Promise((r) => setTimeout(r, 30)); return { data: [] }; },
159
+ async getText(url: string) { this.calls.push(url); return ""; },
160
+ async postJson(url: string) { this.calls.push(url); return {}; },
161
+ };
162
+ const res = await webSearch("q", { env: {}, http: http as any, budgetMs: 5 });
163
+ expect(res.provider).toBe("none");
164
+ expect(res.tried).toEqual(["jina"]); // scrapers skipped — out of budget
165
+ });
166
+
143
167
  it("rejects an empty query", async () => {
144
168
  await expect(webSearch(" ", {})).rejects.toThrow(/query/);
145
169
  });