@xynogen/pix-9router 0.3.3 → 0.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/package.json +4 -2
- package/src/fetch.ts +191 -80
- package/src/format.test.ts +316 -3
- package/src/render.test.ts +114 -25
- package/src/render.ts +49 -6
- package/src/search.ts +206 -100
- package/src/tool-schema.test.ts +40 -0
- package/src/transcribe.test.ts +122 -6
- package/src/transcribe.ts +92 -10
package/src/render.test.ts
CHANGED
|
@@ -18,61 +18,150 @@ function stubComponent() {
|
|
|
18
18
|
return { component, get: () => captured };
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
function context(stub: ReturnType<typeof stubComponent>, overrides: Record<string, unknown> = {}) {
|
|
22
|
+
return {
|
|
23
|
+
lastComponent: stub.component as never,
|
|
24
|
+
isError: false,
|
|
25
|
+
state: {},
|
|
26
|
+
expanded: false,
|
|
27
|
+
invalidate: () => {},
|
|
28
|
+
...overrides,
|
|
29
|
+
} as never;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const config = {
|
|
33
|
+
tool: "fetch",
|
|
34
|
+
target: (_details: { _type: "fetchResult"; chars: number }) => "https://example.com",
|
|
35
|
+
meta: (details: { _type: "fetchResult"; chars: number }) => `${details.chars} chars`,
|
|
36
|
+
};
|
|
37
|
+
|
|
21
38
|
test("renderCall shows title + accent arg", () => {
|
|
22
39
|
const stub = stubComponent();
|
|
23
40
|
const rc = makeRenderCall<{ url: string }>("fetch", (a) => a.url);
|
|
24
|
-
rc({ url: "https://x.com" }, theme,
|
|
25
|
-
lastComponent: stub.component as never,
|
|
26
|
-
isError: false,
|
|
27
|
-
});
|
|
41
|
+
rc({ url: "https://x.com" }, theme, context(stub));
|
|
28
42
|
expect(stub.get()).toBe("[toolTitle]*fetch* [accent]https://x.com");
|
|
29
43
|
});
|
|
30
44
|
|
|
31
|
-
test("
|
|
45
|
+
test("renderCall hides only an effectively collapsed call row", () => {
|
|
46
|
+
const stub = stubComponent();
|
|
47
|
+
const rc = makeRenderCall<{ url: string }>("fetch", (a) => a.url);
|
|
48
|
+
rc({ url: "https://x.com" }, theme, context(stub, { state: { collapsed: true } }));
|
|
49
|
+
expect(stub.get()).toBe("");
|
|
50
|
+
|
|
51
|
+
rc(
|
|
52
|
+
{ url: "https://x.com" },
|
|
53
|
+
theme,
|
|
54
|
+
context(stub, { state: { collapsed: true }, expanded: true }),
|
|
55
|
+
);
|
|
56
|
+
expect(stub.get()).toBe("[toolTitle]*fetch* [accent]https://x.com");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("renderResult renders structured compact details after collapse", () => {
|
|
60
|
+
const stub = stubComponent();
|
|
61
|
+
const rr = makeRenderResult(config);
|
|
62
|
+
rr(
|
|
63
|
+
{
|
|
64
|
+
content: [{ type: "text", text: "full body" }],
|
|
65
|
+
details: { _type: "fetchResult", chars: 9 },
|
|
66
|
+
} as never,
|
|
67
|
+
{ expanded: false, isPartial: false },
|
|
68
|
+
theme,
|
|
69
|
+
context(stub, { state: { collapsed: true } }),
|
|
70
|
+
);
|
|
71
|
+
expect(stub.get()).toContain("[success]✓");
|
|
72
|
+
expect(stub.get()).toContain("https://example.com");
|
|
73
|
+
expect(stub.get()).toContain("9 chars");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("partial updates keep detail visible and do not schedule collapse", () => {
|
|
32
77
|
const stub = stubComponent();
|
|
33
|
-
const
|
|
78
|
+
const state: Record<string, unknown> = {};
|
|
79
|
+
const rr = makeRenderResult(config);
|
|
34
80
|
rr(
|
|
35
|
-
{
|
|
81
|
+
{
|
|
82
|
+
content: [{ type: "text", text: "streaming body" }],
|
|
83
|
+
details: { _type: "fetchResult", chars: 14 },
|
|
84
|
+
} as never,
|
|
85
|
+
{ expanded: false, isPartial: true },
|
|
86
|
+
theme,
|
|
87
|
+
context(stub, { state }),
|
|
88
|
+
);
|
|
89
|
+
expect(stub.get()).toContain("streaming body");
|
|
90
|
+
expect(state.timer).toBeUndefined();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("expanded mode restores full joined text", () => {
|
|
94
|
+
const stub = stubComponent();
|
|
95
|
+
const rr = makeRenderResult(config);
|
|
96
|
+
rr(
|
|
97
|
+
{
|
|
98
|
+
content: [
|
|
99
|
+
{ type: "text", text: "first" },
|
|
100
|
+
{ type: "image", data: "ignored", mimeType: "image/png" },
|
|
101
|
+
{ type: "text", text: "second" },
|
|
102
|
+
],
|
|
103
|
+
details: { _type: "fetchResult", chars: 12 },
|
|
104
|
+
} as never,
|
|
36
105
|
{ expanded: true, isPartial: false },
|
|
37
106
|
theme,
|
|
38
|
-
{
|
|
107
|
+
context(stub, { state: { collapsed: true }, expanded: true }),
|
|
39
108
|
);
|
|
40
|
-
expect(stub.get()).toBe(" [dim]
|
|
109
|
+
expect(stub.get()).toBe(" [dim]first\n [dim]second");
|
|
41
110
|
});
|
|
42
111
|
|
|
43
|
-
test("renderResult caps
|
|
112
|
+
test("renderResult caps the normal preview", () => {
|
|
44
113
|
const stub = stubComponent();
|
|
45
|
-
const rr = makeRenderResult();
|
|
114
|
+
const rr = makeRenderResult(config);
|
|
46
115
|
const body = Array.from({ length: 37 }, (_, i) => `L${i}`).join("\n");
|
|
47
116
|
rr(
|
|
48
|
-
{
|
|
117
|
+
{
|
|
118
|
+
content: [{ type: "text", text: body }],
|
|
119
|
+
details: { _type: "fetchResult", chars: body.length },
|
|
120
|
+
} as never,
|
|
49
121
|
{ expanded: false, isPartial: false },
|
|
50
122
|
theme,
|
|
51
|
-
{
|
|
123
|
+
context(stub, { state: { timer: 1 } }),
|
|
52
124
|
);
|
|
53
|
-
expect(stub.get()).toContain("… 5 more lines");
|
|
125
|
+
expect(stub.get()).toContain("… 5 more lines");
|
|
54
126
|
});
|
|
55
127
|
|
|
56
|
-
test("
|
|
57
|
-
const
|
|
58
|
-
|
|
128
|
+
test("warning and error callbacks select shared status glyphs", () => {
|
|
129
|
+
for (const [status, glyph] of [
|
|
130
|
+
["warning", "⚡"],
|
|
131
|
+
["error", "✗"],
|
|
132
|
+
] as const) {
|
|
133
|
+
const stub = stubComponent();
|
|
134
|
+
const rr = makeRenderResult({ ...config, status: () => status });
|
|
135
|
+
rr(
|
|
136
|
+
{
|
|
137
|
+
content: [{ type: "text", text: "body" }],
|
|
138
|
+
details: { _type: "fetchResult", chars: 4 },
|
|
139
|
+
} as never,
|
|
140
|
+
{ expanded: false, isPartial: false },
|
|
141
|
+
theme,
|
|
142
|
+
context(stub, { state: { collapsed: true } }),
|
|
143
|
+
);
|
|
144
|
+
expect(stub.get()).toContain(`[${status}]${glyph}`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("renderResult renders empty and errors without compacting", () => {
|
|
149
|
+
const rr = makeRenderResult(config);
|
|
150
|
+
const empty = stubComponent();
|
|
59
151
|
rr(
|
|
60
152
|
{ content: [{ type: "text", text: " " }] } as never,
|
|
61
153
|
{ expanded: false, isPartial: false },
|
|
62
154
|
theme,
|
|
63
|
-
|
|
155
|
+
context(empty),
|
|
64
156
|
);
|
|
65
|
-
expect(
|
|
66
|
-
});
|
|
157
|
+
expect(empty.get()).toBe(" [dim](empty)");
|
|
67
158
|
|
|
68
|
-
|
|
69
|
-
const stub = stubComponent();
|
|
70
|
-
const rr = makeRenderResult();
|
|
159
|
+
const failed = stubComponent();
|
|
71
160
|
rr(
|
|
72
161
|
{ content: [{ type: "text", text: "boom" }] } as never,
|
|
73
162
|
{ expanded: false, isPartial: false },
|
|
74
163
|
theme,
|
|
75
|
-
{
|
|
164
|
+
context(failed, { isError: true, state: { collapsed: true } }),
|
|
76
165
|
);
|
|
77
|
-
expect(
|
|
166
|
+
expect(failed.get()).toBe(" [error]boom");
|
|
78
167
|
});
|
package/src/render.ts
CHANGED
|
@@ -16,6 +16,12 @@ import type {
|
|
|
16
16
|
ToolRenderResultOptions,
|
|
17
17
|
} from "@earendil-works/pi-coding-agent";
|
|
18
18
|
import type { Component } from "@earendil-works/pi-tui";
|
|
19
|
+
import { type CollapseState, tickCollapse } from "@xynogen/pix-data/collapse";
|
|
20
|
+
import {
|
|
21
|
+
type CollapsedToolStatus,
|
|
22
|
+
formatCollapsedToolRow,
|
|
23
|
+
hideCollapsedToolCall,
|
|
24
|
+
} from "@xynogen/pix-pretty/utils";
|
|
19
25
|
|
|
20
26
|
interface TextLike extends Component {
|
|
21
27
|
setText(text: string): void;
|
|
@@ -26,6 +32,16 @@ interface TextLike extends Component {
|
|
|
26
32
|
interface RenderCtx {
|
|
27
33
|
lastComponent: Component | undefined;
|
|
28
34
|
isError: boolean;
|
|
35
|
+
state: Record<string, unknown>;
|
|
36
|
+
expanded: boolean;
|
|
37
|
+
invalidate: () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface CompactRendererConfig<TDetails> {
|
|
41
|
+
tool: string;
|
|
42
|
+
target: (details: TDetails) => string;
|
|
43
|
+
meta: (details: TDetails) => string;
|
|
44
|
+
status?: (details: TDetails) => CollapsedToolStatus;
|
|
29
45
|
}
|
|
30
46
|
|
|
31
47
|
type TextCtor = new (text?: string, padX?: number, padY?: number) => TextLike;
|
|
@@ -51,9 +67,11 @@ function getText(lastComponent: Component | undefined): TextLike {
|
|
|
51
67
|
throw new Error("pi-tui Text component unavailable");
|
|
52
68
|
}
|
|
53
69
|
|
|
54
|
-
function
|
|
55
|
-
|
|
56
|
-
|
|
70
|
+
function allText(result: AgentToolResult<unknown>): string {
|
|
71
|
+
return (result.content ?? [])
|
|
72
|
+
.filter((block): block is { type: "text"; text: string } => block.type === "text")
|
|
73
|
+
.map((block) => block.text)
|
|
74
|
+
.join("\n");
|
|
57
75
|
}
|
|
58
76
|
|
|
59
77
|
function dimBody(body: string, theme: Theme, expanded: boolean): string {
|
|
@@ -72,14 +90,20 @@ function dimBody(body: string, theme: Theme, expanded: boolean): string {
|
|
|
72
90
|
export function makeRenderCall<TArgs>(title: string, pickArg: (args: TArgs) => string) {
|
|
73
91
|
return (args: TArgs, theme: Theme, ctx: RenderCtx): Component => {
|
|
74
92
|
const text = getText(ctx.lastComponent);
|
|
93
|
+
if (
|
|
94
|
+
hideCollapsedToolCall(ctx.state as CollapseState, ctx.expanded, (value) =>
|
|
95
|
+
text.setText(value),
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
return text;
|
|
75
99
|
const arg = pickArg(args);
|
|
76
100
|
text.setText(`${theme.fg("toolTitle", theme.bold(title))} ${theme.fg("accent", arg)}`);
|
|
77
101
|
return text;
|
|
78
102
|
};
|
|
79
103
|
}
|
|
80
104
|
|
|
81
|
-
/** Build a `renderResult`
|
|
82
|
-
export function makeRenderResult() {
|
|
105
|
+
/** Build a `renderResult` with a bounded detail preview and optional compact terminal row. */
|
|
106
|
+
export function makeRenderResult<TDetails>(config?: CompactRendererConfig<TDetails>) {
|
|
83
107
|
return (
|
|
84
108
|
result: AgentToolResult<unknown>,
|
|
85
109
|
opts: ToolRenderResultOptions,
|
|
@@ -87,7 +111,7 @@ export function makeRenderResult() {
|
|
|
87
111
|
ctx: RenderCtx,
|
|
88
112
|
): Component => {
|
|
89
113
|
const text = getText(ctx.lastComponent);
|
|
90
|
-
const body =
|
|
114
|
+
const body = allText(result);
|
|
91
115
|
|
|
92
116
|
if (ctx.isError) {
|
|
93
117
|
text.setText(` ${theme.fg("error", body || "Error")}`);
|
|
@@ -98,6 +122,25 @@ export function makeRenderResult() {
|
|
|
98
122
|
return text;
|
|
99
123
|
}
|
|
100
124
|
|
|
125
|
+
const details = result.details as TDetails | undefined;
|
|
126
|
+
if (
|
|
127
|
+
!opts.isPartial &&
|
|
128
|
+
config &&
|
|
129
|
+
details &&
|
|
130
|
+
tickCollapse(config.tool, ctx.state as CollapseState, ctx.invalidate, opts.expanded)
|
|
131
|
+
) {
|
|
132
|
+
text.setText(
|
|
133
|
+
formatCollapsedToolRow(
|
|
134
|
+
theme,
|
|
135
|
+
config.tool,
|
|
136
|
+
config.target(details),
|
|
137
|
+
config.meta(details),
|
|
138
|
+
config.status?.(details) ?? "success",
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
return text;
|
|
142
|
+
}
|
|
143
|
+
|
|
101
144
|
text.setText(dimBody(body, theme, opts.expanded));
|
|
102
145
|
return text;
|
|
103
146
|
};
|
package/src/search.ts
CHANGED
|
@@ -15,6 +15,35 @@ import { makeRenderCall, makeRenderResult } from "./render.js";
|
|
|
15
15
|
|
|
16
16
|
const REQUEST_TIMEOUT_MS = 30_000;
|
|
17
17
|
|
|
18
|
+
type RouterOutcome = "running" | "fallback" | "success" | "cancelled" | "error";
|
|
19
|
+
type SearchType = "web" | "news";
|
|
20
|
+
|
|
21
|
+
export interface SearchResultDetails {
|
|
22
|
+
_type: "searchResult";
|
|
23
|
+
outcome: RouterOutcome;
|
|
24
|
+
query: string;
|
|
25
|
+
searchType: SearchType;
|
|
26
|
+
source?: "api" | "curl-fallback" | "failed";
|
|
27
|
+
resultCount?: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface SearchParams {
|
|
31
|
+
query: string;
|
|
32
|
+
search_type?: SearchType;
|
|
33
|
+
max_results?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
interface SearchResult {
|
|
37
|
+
content: { type: "text"; text: string }[];
|
|
38
|
+
details: SearchResultDetails;
|
|
39
|
+
isError?: boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface SearchDependencies {
|
|
43
|
+
apiPost: typeof apiPost;
|
|
44
|
+
curl: typeof curl;
|
|
45
|
+
}
|
|
46
|
+
|
|
18
47
|
function auth(): string | undefined {
|
|
19
48
|
return process.env.ROUTER_API_KEY;
|
|
20
49
|
}
|
|
@@ -84,25 +113,22 @@ interface SearchResultItem {
|
|
|
84
113
|
author?: string | null;
|
|
85
114
|
}
|
|
86
115
|
|
|
87
|
-
/**
|
|
88
|
-
|
|
89
|
-
* Falls back to pretty-printed JSON when the shape is unexpected.
|
|
90
|
-
*/
|
|
91
|
-
export function formatSearchResults(raw: string): string {
|
|
116
|
+
/** Parse and format a raw exa search response with its authoritative result count. */
|
|
117
|
+
export function parseSearchResults(raw: string): { text: string; count: number } {
|
|
92
118
|
let parsed: unknown;
|
|
93
119
|
try {
|
|
94
120
|
parsed = JSON.parse(raw);
|
|
95
121
|
} catch {
|
|
96
|
-
return raw.slice(0, 20_000);
|
|
122
|
+
return { text: raw.slice(0, 20_000), count: 0 };
|
|
97
123
|
}
|
|
98
124
|
|
|
99
125
|
const obj = parsed as Record<string, unknown>;
|
|
100
126
|
const results = obj?.results;
|
|
101
127
|
if (!Array.isArray(results)) {
|
|
102
|
-
return JSON.stringify(parsed, null, 2).slice(0, 20_000);
|
|
128
|
+
return { text: JSON.stringify(parsed, null, 2).slice(0, 20_000), count: 0 };
|
|
103
129
|
}
|
|
104
130
|
|
|
105
|
-
if (results.length === 0) return "No results.";
|
|
131
|
+
if (results.length === 0) return { text: "No results.", count: 0 };
|
|
106
132
|
|
|
107
133
|
const lines: string[] = [];
|
|
108
134
|
results.forEach((r: SearchResultItem, i: number) => {
|
|
@@ -119,7 +145,148 @@ export function formatSearchResults(raw: string): string {
|
|
|
119
145
|
lines.push("");
|
|
120
146
|
});
|
|
121
147
|
|
|
122
|
-
return
|
|
148
|
+
return {
|
|
149
|
+
text: lines.join("\n").trim().slice(0, 20_000),
|
|
150
|
+
count: results.length,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Format a raw exa search response as a compact markdown list. */
|
|
155
|
+
export function formatSearchResults(raw: string): string {
|
|
156
|
+
return parseSearchResults(raw).text;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isCancelled(error: unknown, signal: AbortSignal | undefined): boolean {
|
|
160
|
+
return signal?.aborted === true || (error instanceof DOMException && error.name === "AbortError");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export async function executeSearch(
|
|
164
|
+
params: SearchParams,
|
|
165
|
+
signal?: AbortSignal,
|
|
166
|
+
onUpdate?: (result: SearchResult) => void,
|
|
167
|
+
dependencies: SearchDependencies = { apiPost, curl },
|
|
168
|
+
): Promise<SearchResult> {
|
|
169
|
+
const max = Math.min(params.max_results ?? 5, 10);
|
|
170
|
+
const searchType = params.search_type ?? "web";
|
|
171
|
+
let apiMsg = "";
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
onUpdate?.({
|
|
175
|
+
content: [{ type: "text", text: `Searching (${searchType}): ${params.query}...` }],
|
|
176
|
+
details: {
|
|
177
|
+
_type: "searchResult",
|
|
178
|
+
outcome: "running",
|
|
179
|
+
query: params.query,
|
|
180
|
+
searchType,
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const raw = await dependencies.apiPost(
|
|
185
|
+
"/search",
|
|
186
|
+
{
|
|
187
|
+
model: "exa",
|
|
188
|
+
query: params.query,
|
|
189
|
+
search_type: searchType,
|
|
190
|
+
max_results: max,
|
|
191
|
+
},
|
|
192
|
+
signal,
|
|
193
|
+
);
|
|
194
|
+
const parsed = parseSearchResults(raw);
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
content: [{ type: "text", text: parsed.text }],
|
|
198
|
+
details: {
|
|
199
|
+
_type: "searchResult",
|
|
200
|
+
outcome: "success",
|
|
201
|
+
query: params.query,
|
|
202
|
+
searchType,
|
|
203
|
+
source: "api",
|
|
204
|
+
resultCount: parsed.count,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
} catch (apiErr: unknown) {
|
|
208
|
+
apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
|
|
209
|
+
if (isCancelled(apiErr, signal)) {
|
|
210
|
+
return {
|
|
211
|
+
content: [{ type: "text", text: `Search cancelled.\nAPI: ${apiMsg}` }],
|
|
212
|
+
details: {
|
|
213
|
+
_type: "searchResult",
|
|
214
|
+
outcome: "cancelled",
|
|
215
|
+
query: params.query,
|
|
216
|
+
searchType,
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
onUpdate?.({
|
|
221
|
+
content: [
|
|
222
|
+
{
|
|
223
|
+
type: "text",
|
|
224
|
+
text: `API failed: ${apiMsg}\nFalling back to curl...`,
|
|
225
|
+
},
|
|
226
|
+
],
|
|
227
|
+
details: {
|
|
228
|
+
_type: "searchResult",
|
|
229
|
+
outcome: "running",
|
|
230
|
+
query: params.query,
|
|
231
|
+
searchType,
|
|
232
|
+
},
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
try {
|
|
237
|
+
const body = JSON.stringify({
|
|
238
|
+
model: "exa",
|
|
239
|
+
query: params.query,
|
|
240
|
+
search_type: searchType,
|
|
241
|
+
max_results: max,
|
|
242
|
+
});
|
|
243
|
+
const raw = await dependencies.curl([
|
|
244
|
+
"-X",
|
|
245
|
+
"POST",
|
|
246
|
+
"-H",
|
|
247
|
+
"Content-Type: application/json",
|
|
248
|
+
...(auth() ? ["-H", `Authorization: Bearer ${auth()}`] : []),
|
|
249
|
+
"-d",
|
|
250
|
+
body,
|
|
251
|
+
`${routerBaseUrl()}/search`,
|
|
252
|
+
]);
|
|
253
|
+
const parsed = parseSearchResults(raw);
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
content: [
|
|
257
|
+
{
|
|
258
|
+
type: "text",
|
|
259
|
+
text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${parsed.text.slice(0, 19_500)}`,
|
|
260
|
+
},
|
|
261
|
+
],
|
|
262
|
+
details: {
|
|
263
|
+
_type: "searchResult",
|
|
264
|
+
outcome: "fallback",
|
|
265
|
+
query: params.query,
|
|
266
|
+
searchType,
|
|
267
|
+
source: "curl-fallback",
|
|
268
|
+
resultCount: parsed.count,
|
|
269
|
+
},
|
|
270
|
+
};
|
|
271
|
+
} catch (curlErr: unknown) {
|
|
272
|
+
const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
|
|
273
|
+
return {
|
|
274
|
+
content: [
|
|
275
|
+
{
|
|
276
|
+
type: "text",
|
|
277
|
+
text: `Search failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
|
|
278
|
+
},
|
|
279
|
+
],
|
|
280
|
+
details: {
|
|
281
|
+
_type: "searchResult",
|
|
282
|
+
outcome: "error",
|
|
283
|
+
query: params.query,
|
|
284
|
+
searchType,
|
|
285
|
+
source: "failed",
|
|
286
|
+
},
|
|
287
|
+
isError: true,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
123
290
|
}
|
|
124
291
|
|
|
125
292
|
export default function registerSearch(pi: ExtensionAPI): void {
|
|
@@ -133,12 +300,38 @@ export default function registerSearch(pi: ExtensionAPI): void {
|
|
|
133
300
|
"Use search when you need up-to-date information or facts from the web.",
|
|
134
301
|
"Set search_type='web' for general web results, search_type='news' for recent news articles.",
|
|
135
302
|
],
|
|
136
|
-
renderCall: makeRenderCall("search", (args) =>
|
|
137
|
-
|
|
303
|
+
renderCall: makeRenderCall<unknown>("search", (args) =>
|
|
304
|
+
String((args as Partial<SearchParams>).query ?? ""),
|
|
305
|
+
),
|
|
306
|
+
renderResult: (
|
|
307
|
+
(renderTerminal) => (result, options, theme, context) =>
|
|
308
|
+
renderTerminal(result, options, theme, {
|
|
309
|
+
...context,
|
|
310
|
+
isError: context.isError && options.expanded,
|
|
311
|
+
})
|
|
312
|
+
)(
|
|
313
|
+
makeRenderResult<SearchResultDetails>({
|
|
314
|
+
tool: "search",
|
|
315
|
+
target: (details) => `“${details.query}”`,
|
|
316
|
+
meta: (details) => {
|
|
317
|
+
if (details.outcome === "error") return "failed";
|
|
318
|
+
if (details.outcome === "cancelled") return "cancelled";
|
|
319
|
+
const count = details.resultCount ?? 0;
|
|
320
|
+
return `${count} ${count === 1 ? "result" : "results"} · ${details.searchType}`;
|
|
321
|
+
},
|
|
322
|
+
status: (details) =>
|
|
323
|
+
details.outcome === "error"
|
|
324
|
+
? "error"
|
|
325
|
+
: details.outcome === "fallback" || details.outcome === "cancelled"
|
|
326
|
+
? "warning"
|
|
327
|
+
: "success",
|
|
328
|
+
}),
|
|
329
|
+
),
|
|
138
330
|
parameters: Type.Object({
|
|
139
331
|
query: Type.String({ description: "Search query" }),
|
|
140
332
|
search_type: StringEnum(["web", "news"] as const, {
|
|
141
|
-
description:
|
|
333
|
+
description:
|
|
334
|
+
'Required choice. Enter exactly "web" for general web results or "news" for recent news articles.',
|
|
142
335
|
}),
|
|
143
336
|
max_results: Type.Optional(
|
|
144
337
|
Type.Number({
|
|
@@ -149,94 +342,7 @@ export default function registerSearch(pi: ExtensionAPI): void {
|
|
|
149
342
|
}),
|
|
150
343
|
|
|
151
344
|
async execute(_toolCallId, params, signal, onUpdate) {
|
|
152
|
-
|
|
153
|
-
const searchType = params.search_type ?? "web";
|
|
154
|
-
let apiMsg = "";
|
|
155
|
-
|
|
156
|
-
try {
|
|
157
|
-
onUpdate?.({
|
|
158
|
-
content: [
|
|
159
|
-
{
|
|
160
|
-
type: "text",
|
|
161
|
-
text: `Searching (${searchType}): ${params.query}...`,
|
|
162
|
-
},
|
|
163
|
-
],
|
|
164
|
-
details: undefined,
|
|
165
|
-
});
|
|
166
|
-
|
|
167
|
-
const raw = await apiPost(
|
|
168
|
-
"/search",
|
|
169
|
-
{
|
|
170
|
-
model: "exa",
|
|
171
|
-
query: params.query,
|
|
172
|
-
search_type: searchType,
|
|
173
|
-
max_results: max,
|
|
174
|
-
},
|
|
175
|
-
signal,
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
return {
|
|
179
|
-
content: [
|
|
180
|
-
{
|
|
181
|
-
type: "text",
|
|
182
|
-
text: formatSearchResults(raw),
|
|
183
|
-
},
|
|
184
|
-
],
|
|
185
|
-
details: { source: "api" },
|
|
186
|
-
};
|
|
187
|
-
} catch (apiErr: unknown) {
|
|
188
|
-
apiMsg = apiErr instanceof Error ? apiErr.message : String(apiErr);
|
|
189
|
-
onUpdate?.({
|
|
190
|
-
content: [
|
|
191
|
-
{
|
|
192
|
-
type: "text",
|
|
193
|
-
text: `API failed: ${apiMsg}\nFalling back to curl...`,
|
|
194
|
-
},
|
|
195
|
-
],
|
|
196
|
-
details: undefined,
|
|
197
|
-
});
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
try {
|
|
201
|
-
const body = JSON.stringify({
|
|
202
|
-
model: "exa",
|
|
203
|
-
query: params.query,
|
|
204
|
-
search_type: searchType,
|
|
205
|
-
max_results: max,
|
|
206
|
-
});
|
|
207
|
-
const raw = await curl([
|
|
208
|
-
"-X",
|
|
209
|
-
"POST",
|
|
210
|
-
"-H",
|
|
211
|
-
"Content-Type: application/json",
|
|
212
|
-
...(auth() ? ["-H", `Authorization: Bearer ${auth()}`] : []),
|
|
213
|
-
"-d",
|
|
214
|
-
body,
|
|
215
|
-
`${routerBaseUrl()}/search`,
|
|
216
|
-
]);
|
|
217
|
-
|
|
218
|
-
return {
|
|
219
|
-
content: [
|
|
220
|
-
{
|
|
221
|
-
type: "text",
|
|
222
|
-
text: `[FALLBACK — curl] API called via curl instead of fetch.\n\n${formatSearchResults(raw).slice(0, 19_500)}`,
|
|
223
|
-
},
|
|
224
|
-
],
|
|
225
|
-
details: { source: "curl-fallback" },
|
|
226
|
-
};
|
|
227
|
-
} catch (curlErr: unknown) {
|
|
228
|
-
const curlMsg = curlErr instanceof Error ? curlErr.message : String(curlErr);
|
|
229
|
-
return {
|
|
230
|
-
content: [
|
|
231
|
-
{
|
|
232
|
-
type: "text",
|
|
233
|
-
text: `Search failed (both API and curl).\nAPI: ${apiMsg}\nCurl: ${curlMsg}`,
|
|
234
|
-
},
|
|
235
|
-
],
|
|
236
|
-
details: { source: "failed" },
|
|
237
|
-
isError: true,
|
|
238
|
-
};
|
|
239
|
-
}
|
|
345
|
+
return executeSearch(params as SearchParams, signal, onUpdate);
|
|
240
346
|
},
|
|
241
347
|
});
|
|
242
348
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import registerFetch from "./fetch.ts";
|
|
3
|
+
import registerSearch from "./search.ts";
|
|
4
|
+
|
|
5
|
+
function captureParameters(register: (pi: never) => void): {
|
|
6
|
+
properties: Record<string, { type?: string; enum?: string[]; description?: string }>;
|
|
7
|
+
} {
|
|
8
|
+
let parameters: unknown;
|
|
9
|
+
register({
|
|
10
|
+
registerTool(tool: { parameters: unknown }) {
|
|
11
|
+
parameters = tool.parameters;
|
|
12
|
+
},
|
|
13
|
+
} as never);
|
|
14
|
+
if (!parameters) throw new Error("tool parameters not captured");
|
|
15
|
+
return parameters as {
|
|
16
|
+
properties: Record<string, { type?: string; enum?: string[]; description?: string }>;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe("enum-like tool parameters", () => {
|
|
21
|
+
test("search_type exposes values and their semantics", () => {
|
|
22
|
+
const searchType = captureParameters(registerSearch).properties.search_type;
|
|
23
|
+
if (!searchType) throw new Error("search_type schema not found");
|
|
24
|
+
|
|
25
|
+
expect(searchType.type).toBe("string");
|
|
26
|
+
expect(searchType.enum).toEqual(["web", "news"]);
|
|
27
|
+
expect(searchType.description).toContain('exactly "web"');
|
|
28
|
+
expect(searchType.description).toContain('"news"');
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("fetch format exposes values and their semantics", () => {
|
|
32
|
+
const format = captureParameters(registerFetch).properties.format;
|
|
33
|
+
if (!format) throw new Error("format schema not found");
|
|
34
|
+
|
|
35
|
+
expect(format.type).toBe("string");
|
|
36
|
+
expect(format.enum).toEqual(["markdown", "text", "html"]);
|
|
37
|
+
expect(format.description).toContain('exactly "markdown"');
|
|
38
|
+
expect(format.description).toContain('"html"');
|
|
39
|
+
});
|
|
40
|
+
});
|