gloomberb 0.13.1 → 0.13.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.
- package/package.json +1 -1
- package/src/api-client/index.test.ts +34 -0
- package/src/api-client/request.ts +4 -1
- package/src/cli/pane-functions/cloud-session.test.ts +54 -0
- package/src/cli/pane-functions/cloud-session.ts +38 -0
- package/src/cli/pane-functions/index.ts +5 -1
- package/src/cli/pane-functions/screenshot.ts +2 -19
- package/src/components/command-bar/assist/runtime.ts +38 -30
- package/src/components/command-bar/surface/assist.test.tsx +43 -0
- package/src/news/types.ts +1 -0
- package/src/plugins/builtin/chat/controller/channels.ts +2 -1
- package/src/plugins/builtin/chat/controller/index.ts +13 -5
- package/src/plugins/builtin/chat/controller/realtime.ts +24 -0
- package/src/plugins/builtin/chat/controller/session-runtime.ts +76 -8
- package/src/plugins/builtin/chat/controller/state.ts +1 -0
- package/src/plugins/builtin/chat/controller.test.ts +260 -4
- package/src/plugins/builtin/cloud/askg/executor.test.ts +160 -0
- package/src/plugins/builtin/cloud/askg/executor.ts +429 -0
- package/src/plugins/builtin/cloud/askg/manifest.test.ts +153 -0
- package/src/plugins/builtin/cloud/askg/manifest.ts +314 -0
- package/src/plugins/builtin/cloud/askg/protocol.test.ts +3 -2
- package/src/plugins/builtin/cloud/askg/protocol.ts +16 -4
- package/src/plugins/builtin/cloud/askg/undo.test.ts +44 -0
- package/src/plugins/builtin/cloud/askg/undo.ts +277 -0
- package/src/plugins/builtin/cloud/askg/verify.ts +130 -0
- package/src/plugins/builtin/cloud/plugin.tsx +2 -0
- package/src/plugins/builtin/congress-trades/client.ts +11 -0
- package/src/plugins/builtin/congress-trades/detail.tsx +3 -3
- package/src/plugins/builtin/congress-trades/headless.test.ts +98 -0
- package/src/plugins/builtin/congress-trades/headless.ts +145 -0
- package/src/plugins/builtin/congress-trades/pane.tsx +3 -3
- package/src/plugins/builtin/dividend-yield/headless.test.ts +88 -0
- package/src/plugins/builtin/dividend-yield/headless.ts +135 -0
- package/src/plugins/builtin/dividend-yield/index.tsx +12 -8
- package/src/plugins/builtin/dividend-yield/model.ts +4 -22
- package/src/plugins/builtin/dividend-yield/view.ts +17 -0
- package/src/plugins/builtin/earnings/headless.test.ts +82 -0
- package/src/plugins/builtin/earnings/headless.ts +162 -0
- package/src/plugins/builtin/earnings/index.tsx +2 -0
- package/src/plugins/builtin/earnings-calls/data.ts +29 -8
- package/src/plugins/builtin/earnings-calls/headless.test.ts +192 -0
- package/src/plugins/builtin/earnings-calls/headless.ts +300 -0
- package/src/plugins/builtin/earnings-calls/index.tsx +19 -9
- package/src/plugins/builtin/earnings-calls/model.ts +238 -0
- package/src/plugins/builtin/earnings-calls/transcript-view.tsx +8 -9
- package/src/plugins/builtin/holders/client.ts +29 -0
- package/src/plugins/builtin/holders/headless.test.ts +58 -0
- package/src/plugins/builtin/holders/headless.ts +162 -0
- package/src/plugins/builtin/holders/index.tsx +14 -8
- package/src/plugins/builtin/holders/pane.tsx +7 -1
- package/src/plugins/builtin/insider/client.ts +50 -0
- package/src/plugins/builtin/insider/headless.test.ts +122 -0
- package/src/plugins/builtin/insider/headless.ts +117 -0
- package/src/plugins/builtin/insider/index.tsx +21 -50
- package/src/plugins/builtin/insider/insider-data.ts +1 -1
- package/src/plugins/builtin/insider/model.ts +72 -0
- package/src/plugins/builtin/ipo-calendar/client.ts +15 -0
- package/src/plugins/builtin/ipo-calendar/headless.test.ts +89 -0
- package/src/plugins/builtin/ipo-calendar/headless.ts +127 -0
- package/src/plugins/builtin/ipo-calendar/index.tsx +2 -0
- package/src/plugins/builtin/ipo-calendar/model.ts +3 -15
- package/src/plugins/builtin/news/headless.test.ts +138 -0
- package/src/plugins/builtin/news/headless.ts +190 -0
- package/src/plugins/builtin/news/index.tsx +13 -9
- package/src/plugins/builtin/news/wire/index.tsx +2 -1
- package/src/plugins/builtin/research/analyst-headless.test.ts +60 -0
- package/src/plugins/builtin/research/analyst-headless.ts +172 -0
- package/src/plugins/builtin/research/analyst-model.ts +219 -0
- package/src/plugins/builtin/research/analyst-pane.tsx +31 -221
- package/src/plugins/builtin/research/client.ts +12 -0
- package/src/plugins/builtin/research/headless.test.ts +93 -0
- package/src/plugins/builtin/research/headless.ts +161 -0
- package/src/plugins/builtin/research/index.tsx +28 -18
- package/src/plugins/builtin/sec/client.ts +16 -0
- package/src/plugins/builtin/sec/headless.test.ts +72 -0
- package/src/plugins/builtin/sec/headless.ts +69 -0
- package/src/plugins/builtin/sec/index.tsx +20 -75
- package/src/plugins/builtin/sec/model.ts +89 -0
- package/src/plugins/builtin/short-interest/client.ts +5 -2
- package/src/plugins/builtin/short-interest/headless.test.ts +96 -0
- package/src/plugins/builtin/short-interest/headless.ts +111 -0
- package/src/plugins/builtin/short-interest/index.tsx +15 -9
- package/src/plugins/builtin/thirteenf/data.ts +13 -11
- package/src/plugins/builtin/thirteenf/headless.test.ts +112 -0
- package/src/plugins/builtin/thirteenf/headless.ts +286 -0
- package/src/plugins/builtin/thirteenf/index.tsx +4 -0
- package/src/plugins/builtin/thirteenf/model.ts +13 -0
- package/src/plugins/builtin/thirteenf/table.ts +3 -15
- package/src/plugins/prediction-markets/headless.test.ts +119 -0
- package/src/plugins/prediction-markets/headless.ts +255 -0
- package/src/plugins/prediction-markets/index.tsx +2 -0
- package/src/sources/gloomberb-cloud/news.ts +1 -0
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
import type { MarketContext } from "../../../../cli/types";
|
|
2
|
+
import type { PaneFunctionCatalog } from "../../../../cli/pane-functions/catalog";
|
|
3
|
+
import { withPersistedCloudSession } from "../../../../cli/pane-functions/cloud-session";
|
|
4
|
+
import {
|
|
5
|
+
loadResolvedHeadlessPaneModel,
|
|
6
|
+
serializeHeadlessPaneResult,
|
|
7
|
+
} from "../../../../cli/pane-functions/headless";
|
|
8
|
+
import { resolvePaneFunction } from "../../../../cli/pane-functions/resolver";
|
|
9
|
+
import { stableStringify } from "../../../../remote/revision";
|
|
10
|
+
import type {
|
|
11
|
+
HeadlessBundleResult,
|
|
12
|
+
HeadlessPaneDefinition,
|
|
13
|
+
HeadlessPaneResult,
|
|
14
|
+
HeadlessSeriesResult,
|
|
15
|
+
HeadlessSnapshotResult,
|
|
16
|
+
} from "../../../../types/headless";
|
|
17
|
+
import {
|
|
18
|
+
MAX_TOOL_RESULT_BYTES,
|
|
19
|
+
type ASKGToolCallEvent,
|
|
20
|
+
type ClientToolManifest,
|
|
21
|
+
type JsonValue,
|
|
22
|
+
type ToolResultPayload,
|
|
23
|
+
type ToolResultStatus,
|
|
24
|
+
} from "./protocol";
|
|
25
|
+
import {
|
|
26
|
+
remoteRequestForTool,
|
|
27
|
+
resolveRemoteToolBinding,
|
|
28
|
+
} from "./manifest";
|
|
29
|
+
import {
|
|
30
|
+
ASKGUndoManager,
|
|
31
|
+
type AppliedUndo,
|
|
32
|
+
type InProcessRemoteControlHandler,
|
|
33
|
+
} from "./undo";
|
|
34
|
+
|
|
35
|
+
interface HeadlessExecution {
|
|
36
|
+
result: unknown;
|
|
37
|
+
rowCount: number;
|
|
38
|
+
errors?: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface HeadlessToolCall {
|
|
42
|
+
name: string;
|
|
43
|
+
args: Record<string, JsonValue>;
|
|
44
|
+
manifest: ClientToolManifest;
|
|
45
|
+
signal: AbortSignal;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export type HeadlessToolExecutor = (call: HeadlessToolCall) => Promise<HeadlessExecution>;
|
|
49
|
+
|
|
50
|
+
export interface ASKGToolExecutorDependencies {
|
|
51
|
+
manifests: readonly ClientToolManifest[];
|
|
52
|
+
registry: PaneFunctionCatalog;
|
|
53
|
+
context: MarketContext;
|
|
54
|
+
remoteHandler: InProcessRemoteControlHandler;
|
|
55
|
+
headlessExecutor?: HeadlessToolExecutor;
|
|
56
|
+
undoManager?: ASKGUndoManager;
|
|
57
|
+
now?: () => number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ASKGToolExecutionOptions {
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
confirmed?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ASKGToolExecutor {
|
|
66
|
+
execute(
|
|
67
|
+
call: ASKGToolCallEvent,
|
|
68
|
+
options?: ASKGToolExecutionOptions,
|
|
69
|
+
): Promise<ToolResultPayload>;
|
|
70
|
+
undo(token: string, signal?: AbortSignal): Promise<AppliedUndo>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface ExecutionValue {
|
|
74
|
+
status: ToolResultStatus;
|
|
75
|
+
result?: unknown;
|
|
76
|
+
rowCount?: number;
|
|
77
|
+
truncated?: boolean;
|
|
78
|
+
note?: string;
|
|
79
|
+
rev?: string;
|
|
80
|
+
undoToken?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const ARGUMENT_KEYS = new Set(["symbol", "symbols", "text"]);
|
|
84
|
+
|
|
85
|
+
class ToolTimeoutError extends Error {}
|
|
86
|
+
class ToolCancelledError extends Error {}
|
|
87
|
+
|
|
88
|
+
function shortReason(error: unknown): string {
|
|
89
|
+
const text = error instanceof Error ? error.message : String(error);
|
|
90
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 320) || "Unknown error.";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function appendNote(current: string | undefined, next: string): string {
|
|
94
|
+
return current ? `${current} ${next}` : next;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function normalizeJson(value: unknown, seen = new WeakSet<object>()): JsonValue {
|
|
98
|
+
if (value == null) return null;
|
|
99
|
+
if (typeof value === "string" || typeof value === "boolean") return value;
|
|
100
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : null;
|
|
101
|
+
if (typeof value === "bigint") return value.toString();
|
|
102
|
+
if (value instanceof Date) return value.toISOString();
|
|
103
|
+
if (value instanceof Error) return { name: value.name, message: value.message };
|
|
104
|
+
if (typeof value !== "object") return String(value);
|
|
105
|
+
if (seen.has(value)) return "[Circular]";
|
|
106
|
+
seen.add(value);
|
|
107
|
+
if (Array.isArray(value)) {
|
|
108
|
+
const result = value.map((entry) => normalizeJson(entry, seen));
|
|
109
|
+
seen.delete(value);
|
|
110
|
+
return result;
|
|
111
|
+
}
|
|
112
|
+
const result: Record<string, JsonValue> = {};
|
|
113
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
114
|
+
result[key] = normalizeJson((value as Record<string, unknown>)[key], seen);
|
|
115
|
+
}
|
|
116
|
+
seen.delete(value);
|
|
117
|
+
return result;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function encodedSize(value: unknown): number {
|
|
121
|
+
return new TextEncoder().encode(JSON.stringify(value)).byteLength;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function withBoundedPayload(payload: ToolResultPayload): ToolResultPayload {
|
|
125
|
+
if (encodedSize(payload) <= MAX_TOOL_RESULT_BYTES) return payload;
|
|
126
|
+
|
|
127
|
+
const resultText = stableStringify(payload.result ?? null);
|
|
128
|
+
const originalBytes = encodedSize(payload.result ?? null);
|
|
129
|
+
const note = appendNote(
|
|
130
|
+
payload.note,
|
|
131
|
+
`Result exceeded ${MAX_TOOL_RESULT_BYTES} bytes. result.jsonPreview contains a canonical JSON prefix.`,
|
|
132
|
+
);
|
|
133
|
+
const status = payload.status === "ok" ? "partial" : payload.status;
|
|
134
|
+
let low = 0;
|
|
135
|
+
let high = resultText.length;
|
|
136
|
+
let best: ToolResultPayload = {
|
|
137
|
+
...payload,
|
|
138
|
+
status,
|
|
139
|
+
result: { jsonPreview: "", originalBytes },
|
|
140
|
+
truncated: true,
|
|
141
|
+
note,
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
while (low <= high) {
|
|
145
|
+
const middle = Math.floor((low + high) / 2);
|
|
146
|
+
const candidate: ToolResultPayload = {
|
|
147
|
+
...best,
|
|
148
|
+
result: { jsonPreview: resultText.slice(0, middle), originalBytes },
|
|
149
|
+
};
|
|
150
|
+
if (encodedSize(candidate) <= MAX_TOOL_RESULT_BYTES) {
|
|
151
|
+
best = candidate;
|
|
152
|
+
low = middle + 1;
|
|
153
|
+
} else {
|
|
154
|
+
high = middle - 1;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
if (encodedSize(best) <= MAX_TOOL_RESULT_BYTES) return best;
|
|
159
|
+
const withoutResult: ToolResultPayload = {
|
|
160
|
+
turnId: payload.turnId,
|
|
161
|
+
toolCallId: payload.toolCallId,
|
|
162
|
+
status,
|
|
163
|
+
rowCount: payload.rowCount,
|
|
164
|
+
truncated: true,
|
|
165
|
+
elapsedMs: payload.elapsedMs,
|
|
166
|
+
note: "Result exceeded the client payload limit and was omitted.",
|
|
167
|
+
...(payload.rev ? { rev: payload.rev } : {}),
|
|
168
|
+
...(payload.undoToken ? { undoToken: payload.undoToken } : {}),
|
|
169
|
+
};
|
|
170
|
+
return withoutResult;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function rawArgumentFor(manifest: ClientToolManifest, args: Record<string, JsonValue>): string {
|
|
174
|
+
switch (manifest.argument?.kind) {
|
|
175
|
+
case "none":
|
|
176
|
+
case undefined:
|
|
177
|
+
return "";
|
|
178
|
+
case "ticker": {
|
|
179
|
+
const symbol = args.symbol;
|
|
180
|
+
if (symbol == null && manifest.argument.optional) return "";
|
|
181
|
+
if (typeof symbol !== "string") throw new Error(`${manifest.name} requires a symbol string.`);
|
|
182
|
+
return symbol;
|
|
183
|
+
}
|
|
184
|
+
case "tickers":
|
|
185
|
+
case "symbol-list": {
|
|
186
|
+
const symbols = args.symbols;
|
|
187
|
+
if (symbols == null && manifest.argument.optional) return "";
|
|
188
|
+
if (!Array.isArray(symbols) || !symbols.every((symbol) => typeof symbol === "string")) {
|
|
189
|
+
throw new Error(`${manifest.name} requires a symbols array.`);
|
|
190
|
+
}
|
|
191
|
+
return symbols.join(",");
|
|
192
|
+
}
|
|
193
|
+
case "free-text": {
|
|
194
|
+
const text = args.text;
|
|
195
|
+
if (text == null && manifest.argument.optional) return "";
|
|
196
|
+
if (typeof text !== "string") throw new Error(`${manifest.name} requires a text string.`);
|
|
197
|
+
return text;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function optionArgsFor(manifest: ClientToolManifest, args: Record<string, JsonValue>): Record<string, string | true> {
|
|
203
|
+
const declared = new Set(manifest.options?.map(({ key }) => key) ?? []);
|
|
204
|
+
const options: Record<string, string | true> = {};
|
|
205
|
+
for (const [key, value] of Object.entries(args)) {
|
|
206
|
+
if (ARGUMENT_KEYS.has(key)) continue;
|
|
207
|
+
if (!declared.has(key)) throw new Error(`Unknown option "${key}" for ${manifest.name}.`);
|
|
208
|
+
if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") {
|
|
209
|
+
throw new Error(`Option "${key}" for ${manifest.name} must be a scalar value.`);
|
|
210
|
+
}
|
|
211
|
+
options[key] = value === true ? true : String(value);
|
|
212
|
+
}
|
|
213
|
+
return options;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function headlessRowCount(definition: HeadlessPaneDefinition, result: HeadlessPaneResult): number {
|
|
217
|
+
switch (definition.shape) {
|
|
218
|
+
case "rows":
|
|
219
|
+
return (result as { rows: unknown[] }).rows.length;
|
|
220
|
+
case "bundle":
|
|
221
|
+
return (result as HeadlessBundleResult).sections.reduce((count, section) => (
|
|
222
|
+
count + ("rows" in section && section.rows ? section.rows.length : section.entries.length)
|
|
223
|
+
), 0);
|
|
224
|
+
case "series":
|
|
225
|
+
return (result as HeadlessSeriesResult).series.reduce(
|
|
226
|
+
(count, series) => count + series.points.length,
|
|
227
|
+
0,
|
|
228
|
+
);
|
|
229
|
+
case "snapshot":
|
|
230
|
+
return (result as HeadlessSnapshotResult).items.length;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function inferRemoteRowCount(value: unknown): number | undefined {
|
|
235
|
+
if (Array.isArray(value)) return value.length;
|
|
236
|
+
if (value == null || typeof value !== "object") return undefined;
|
|
237
|
+
const record = value as Record<string, unknown>;
|
|
238
|
+
for (const key of ["rows", "items", "panes", "sections", "series", "results"]) {
|
|
239
|
+
if (Array.isArray(record[key])) return record[key].length;
|
|
240
|
+
}
|
|
241
|
+
return 1;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function effectiveTimeoutMs(call: ASKGToolCallEvent, now: number): number {
|
|
245
|
+
const declared = Math.max(0, call.timeoutMs);
|
|
246
|
+
const expiresAt = Date.parse(call.expiresAt);
|
|
247
|
+
if (!Number.isFinite(expiresAt)) return declared;
|
|
248
|
+
return Math.max(0, Math.min(declared, expiresAt - now));
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
async function runWithDeadline<T>(
|
|
252
|
+
run: (signal: AbortSignal) => Promise<T>,
|
|
253
|
+
timeoutMs: number,
|
|
254
|
+
externalSignal?: AbortSignal,
|
|
255
|
+
): Promise<T> {
|
|
256
|
+
if (externalSignal?.aborted) throw new ToolCancelledError("Tool call was cancelled.");
|
|
257
|
+
if (timeoutMs <= 0) throw new ToolTimeoutError("Tool call timed out.");
|
|
258
|
+
|
|
259
|
+
const controller = new AbortController();
|
|
260
|
+
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
|
|
261
|
+
let removeAbortListener: (() => void) | undefined;
|
|
262
|
+
const gate = new Promise<never>((_resolve, reject) => {
|
|
263
|
+
timeoutHandle = setTimeout(() => {
|
|
264
|
+
const error = new ToolTimeoutError("Tool call timed out.");
|
|
265
|
+
controller.abort(error);
|
|
266
|
+
reject(error);
|
|
267
|
+
}, timeoutMs);
|
|
268
|
+
if (externalSignal) {
|
|
269
|
+
const onAbort = () => {
|
|
270
|
+
const error = new ToolCancelledError("Tool call was cancelled.");
|
|
271
|
+
controller.abort(error);
|
|
272
|
+
reject(error);
|
|
273
|
+
};
|
|
274
|
+
externalSignal.addEventListener("abort", onAbort, { once: true });
|
|
275
|
+
removeAbortListener = () => externalSignal.removeEventListener("abort", onAbort);
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
try {
|
|
280
|
+
return await Promise.race([run(controller.signal), gate]);
|
|
281
|
+
} finally {
|
|
282
|
+
if (timeoutHandle) clearTimeout(timeoutHandle);
|
|
283
|
+
removeAbortListener?.();
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function defaultHeadlessExecutor(
|
|
288
|
+
registry: PaneFunctionCatalog,
|
|
289
|
+
context: MarketContext,
|
|
290
|
+
): HeadlessToolExecutor {
|
|
291
|
+
return async ({ name, args, manifest, signal }) => {
|
|
292
|
+
const rawArgument = rawArgumentFor(manifest, args);
|
|
293
|
+
const resolved = await resolvePaneFunction(registry, context, {
|
|
294
|
+
target: name,
|
|
295
|
+
arg: rawArgument,
|
|
296
|
+
options: optionArgsFor(manifest, args),
|
|
297
|
+
outputPath: null,
|
|
298
|
+
width: 1280,
|
|
299
|
+
height: 720,
|
|
300
|
+
theme: null,
|
|
301
|
+
scale: 1,
|
|
302
|
+
watermark: null,
|
|
303
|
+
requireBotSafe: false,
|
|
304
|
+
}, { strictHeadlessOptions: true });
|
|
305
|
+
const loaded = await withPersistedCloudSession(
|
|
306
|
+
context,
|
|
307
|
+
() => loadResolvedHeadlessPaneModel(resolved, context, rawArgument, signal),
|
|
308
|
+
);
|
|
309
|
+
return {
|
|
310
|
+
result: serializeHeadlessPaneResult(loaded.definition, loaded.result),
|
|
311
|
+
rowCount: headlessRowCount(loaded.definition, loaded.result),
|
|
312
|
+
...(loaded.result.errors?.length ? { errors: loaded.result.errors } : {}),
|
|
313
|
+
};
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function createASKGToolExecutor(
|
|
318
|
+
dependencies: ASKGToolExecutorDependencies,
|
|
319
|
+
): ASKGToolExecutor {
|
|
320
|
+
const manifests = new Map(dependencies.manifests.map((manifest) => [manifest.name, manifest]));
|
|
321
|
+
const now = dependencies.now ?? Date.now;
|
|
322
|
+
const executeHeadless = dependencies.headlessExecutor
|
|
323
|
+
?? defaultHeadlessExecutor(dependencies.registry, dependencies.context);
|
|
324
|
+
const undoManager = dependencies.undoManager ?? new ASKGUndoManager(dependencies.remoteHandler);
|
|
325
|
+
|
|
326
|
+
const execute = async (
|
|
327
|
+
call: ASKGToolCallEvent,
|
|
328
|
+
options: ASKGToolExecutionOptions = {},
|
|
329
|
+
): Promise<ToolResultPayload> => {
|
|
330
|
+
const startedAt = now();
|
|
331
|
+
const base = (value: ExecutionValue): ToolResultPayload => withBoundedPayload({
|
|
332
|
+
turnId: call.turnId,
|
|
333
|
+
toolCallId: call.toolCallId,
|
|
334
|
+
status: value.status,
|
|
335
|
+
...(value.result !== undefined ? { result: normalizeJson(value.result) } : {}),
|
|
336
|
+
...(value.rowCount !== undefined ? { rowCount: value.rowCount } : {}),
|
|
337
|
+
truncated: value.truncated ?? false,
|
|
338
|
+
elapsedMs: Math.max(0, now() - startedAt),
|
|
339
|
+
...(value.note ? { note: value.note } : {}),
|
|
340
|
+
...(value.rev ? { rev: value.rev } : {}),
|
|
341
|
+
...(value.undoToken ? { undoToken: value.undoToken } : {}),
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
const manifest = manifests.get(call.name);
|
|
345
|
+
if (!manifest) {
|
|
346
|
+
return base({ status: "denied", note: `Tool "${call.name}" was not advertised by this client.` });
|
|
347
|
+
}
|
|
348
|
+
if (call.writeTier !== manifest.writeTier) {
|
|
349
|
+
return base({
|
|
350
|
+
status: "denied",
|
|
351
|
+
note: `Tool tier mismatch: advertised ${manifest.writeTier}, received ${call.writeTier}.`,
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
if ((manifest.confirm === "always" || call.requiresConfirmation) && !options.confirmed) {
|
|
355
|
+
return base({ status: "denied", note: "This tool call requires local confirmation." });
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const timeoutMs = Math.min(manifest.timeoutMs, effectiveTimeoutMs(call, now()));
|
|
359
|
+
try {
|
|
360
|
+
const value = await runWithDeadline(async (signal): Promise<ExecutionValue> => {
|
|
361
|
+
if (manifest.source === "headless") {
|
|
362
|
+
const loaded = await executeHeadless({ name: call.name, args: call.args, manifest, signal });
|
|
363
|
+
const errors = loaded.errors?.filter(Boolean) ?? [];
|
|
364
|
+
return {
|
|
365
|
+
status: errors.length > 0 ? (loaded.rowCount > 0 ? "partial" : "error") : "ok",
|
|
366
|
+
result: loaded.result,
|
|
367
|
+
rowCount: loaded.rowCount,
|
|
368
|
+
...(errors.length > 0 ? {
|
|
369
|
+
truncated: loaded.rowCount > 0,
|
|
370
|
+
note: errors.map(shortReason).join(" "),
|
|
371
|
+
} : {}),
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
const binding = resolveRemoteToolBinding(call.name);
|
|
376
|
+
if (!binding) throw new Error(`Remote tool "${call.name}" has no in-process binding.`);
|
|
377
|
+
let preparedUndo: Awaited<ReturnType<ASKGUndoManager["prepare"]>> = null;
|
|
378
|
+
let undoNote: string | undefined;
|
|
379
|
+
if (manifest.writeTier === "ui-write" && binding.kind === "operation") {
|
|
380
|
+
try {
|
|
381
|
+
preparedUndo = await undoManager.prepare(binding.operation, call.args, signal);
|
|
382
|
+
} catch (error) {
|
|
383
|
+
undoNote = `Undo is unavailable: ${shortReason(error)}`;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const response = await dependencies.remoteHandler(remoteRequestForTool(binding, call.args));
|
|
388
|
+
if (!response.ok) {
|
|
389
|
+
return {
|
|
390
|
+
status: "error",
|
|
391
|
+
result: { code: response.error.code, ...(response.error.details !== undefined ? { details: response.error.details } : {}) },
|
|
392
|
+
note: shortReason(response.error.message),
|
|
393
|
+
};
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
let undoToken: string | undefined;
|
|
397
|
+
if (preparedUndo) {
|
|
398
|
+
try {
|
|
399
|
+
undoToken = await undoManager.commit(preparedUndo, signal) ?? undefined;
|
|
400
|
+
} catch (error) {
|
|
401
|
+
undoNote = `Undo is unavailable: ${shortReason(error)}`;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
status: "ok",
|
|
406
|
+
result: response.data,
|
|
407
|
+
rowCount: inferRemoteRowCount(response.data),
|
|
408
|
+
...(undoNote ? { note: undoNote } : {}),
|
|
409
|
+
...(response.rev ? { rev: response.rev } : response.state?.rev ? { rev: response.state.rev } : {}),
|
|
410
|
+
...(undoToken ? { undoToken } : {}),
|
|
411
|
+
};
|
|
412
|
+
}, timeoutMs, options.signal);
|
|
413
|
+
return base(value);
|
|
414
|
+
} catch (error) {
|
|
415
|
+
if (error instanceof ToolTimeoutError) {
|
|
416
|
+
return base({ status: "timeout", note: "Tool call timed out." });
|
|
417
|
+
}
|
|
418
|
+
if (error instanceof ToolCancelledError || options.signal?.aborted) {
|
|
419
|
+
return base({ status: "cancelled", note: "Tool call was cancelled." });
|
|
420
|
+
}
|
|
421
|
+
return base({ status: "error", note: shortReason(error) });
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
return {
|
|
426
|
+
execute,
|
|
427
|
+
undo: (token, signal) => undoManager.undo(token, signal),
|
|
428
|
+
};
|
|
429
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { PaneFunctionCatalog } from "../../../../cli/pane-functions/catalog";
|
|
3
|
+
import type {
|
|
4
|
+
HeadlessPaneArgumentDef,
|
|
5
|
+
HeadlessPaneDefinition,
|
|
6
|
+
PaneDef,
|
|
7
|
+
PaneTemplateDef,
|
|
8
|
+
} from "../../../../types/plugin";
|
|
9
|
+
import {
|
|
10
|
+
buildASKGToolManifests,
|
|
11
|
+
hashASKGToolManifests,
|
|
12
|
+
} from "./manifest";
|
|
13
|
+
import type { ClientToolManifest } from "./protocol";
|
|
14
|
+
|
|
15
|
+
function headless(argument: HeadlessPaneArgumentDef): HeadlessPaneDefinition<"rows"> {
|
|
16
|
+
return {
|
|
17
|
+
shape: "rows",
|
|
18
|
+
argument,
|
|
19
|
+
options: [{
|
|
20
|
+
key: "limit",
|
|
21
|
+
description: "Maximum rows.",
|
|
22
|
+
type: "integer",
|
|
23
|
+
minimum: 1,
|
|
24
|
+
maximum: 100,
|
|
25
|
+
defaultValue: 10,
|
|
26
|
+
settingKey: "limit",
|
|
27
|
+
pluginState: { pluginId: "test" },
|
|
28
|
+
}],
|
|
29
|
+
columns: [{ key: "value", header: "Value", format: String }],
|
|
30
|
+
load: () => ({ rows: [] }),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function pane(id: string): PaneDef {
|
|
35
|
+
return {
|
|
36
|
+
id,
|
|
37
|
+
name: id,
|
|
38
|
+
component: () => null,
|
|
39
|
+
defaultPosition: "left",
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function template(
|
|
44
|
+
id: string,
|
|
45
|
+
paneId: string,
|
|
46
|
+
prefix: string,
|
|
47
|
+
argument: HeadlessPaneArgumentDef,
|
|
48
|
+
): PaneTemplateDef {
|
|
49
|
+
return {
|
|
50
|
+
id,
|
|
51
|
+
paneId,
|
|
52
|
+
label: id,
|
|
53
|
+
description: `Read ${id}.`,
|
|
54
|
+
shortcut: { prefix, argKind: "text" },
|
|
55
|
+
headless: headless(argument),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function registry(templates: PaneTemplateDef[]): PaneFunctionCatalog {
|
|
60
|
+
return {
|
|
61
|
+
panes: new Map(templates.map(({ paneId }) => [paneId, pane(paneId)])),
|
|
62
|
+
paneTemplates: new Map(templates.map((entry) => [entry.id, entry])),
|
|
63
|
+
destroy() {},
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function headlessTools(catalog: PaneFunctionCatalog): ClientToolManifest[] {
|
|
68
|
+
return buildASKGToolManifests(catalog).tools.filter(({ source }) => source === "headless");
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("ASKG client manifest", () => {
|
|
72
|
+
test("projects every headless argument kind and strips renderer-only fields", () => {
|
|
73
|
+
const tools = headlessTools(registry([
|
|
74
|
+
template("none", "pane-none", "NON", { kind: "none" }),
|
|
75
|
+
template("ticker", "pane-ticker", "TIK", { kind: "ticker", placeholder: "symbol" }),
|
|
76
|
+
template("tickers", "pane-tickers", "TKS", { kind: "tickers", minimum: 2, maximum: 4 }),
|
|
77
|
+
template("symbols", "pane-symbols", "SYM", { kind: "symbol-list", optional: true }),
|
|
78
|
+
template("text", "pane-text", "TXT", { kind: "free-text", description: "Search phrase." }),
|
|
79
|
+
]));
|
|
80
|
+
|
|
81
|
+
expect(tools.map(({ name, argument }) => [name, argument?.kind])).toEqual([
|
|
82
|
+
["non", "none"],
|
|
83
|
+
["sym", "symbol-list"],
|
|
84
|
+
["tik", "ticker"],
|
|
85
|
+
["tks", "tickers"],
|
|
86
|
+
["txt", "free-text"],
|
|
87
|
+
]);
|
|
88
|
+
expect(tools[0]?.options?.[0]).toEqual({
|
|
89
|
+
key: "limit",
|
|
90
|
+
description: "Maximum rows.",
|
|
91
|
+
type: "integer",
|
|
92
|
+
minimum: 1,
|
|
93
|
+
maximum: 100,
|
|
94
|
+
defaultValue: 10,
|
|
95
|
+
});
|
|
96
|
+
expect(tools[0]?.columns).toEqual([{ key: "value", header: "Value" }]);
|
|
97
|
+
expect(tools.every(({ writeTier, confirm }) => writeTier === "read" && confirm === "never")).toBe(true);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("projects remote operation schemas and confirmation tiers", () => {
|
|
101
|
+
const { tools } = buildASKGToolManifests(registry([]));
|
|
102
|
+
const notify = tools.find(({ name }) => name === "app.notify");
|
|
103
|
+
const deleteLayout = tools.find(({ name }) => name === "layout.delete");
|
|
104
|
+
|
|
105
|
+
expect(notify).toMatchObject({
|
|
106
|
+
source: "remote-op",
|
|
107
|
+
writeTier: "ui-write",
|
|
108
|
+
confirm: "never",
|
|
109
|
+
inputSchema: { required: ["body"] },
|
|
110
|
+
});
|
|
111
|
+
expect(deleteLayout).toMatchObject({
|
|
112
|
+
source: "remote-op",
|
|
113
|
+
writeTier: "user-data",
|
|
114
|
+
confirm: "always",
|
|
115
|
+
inputSchema: { required: ["index"] },
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("hash is order-independent and changes with the surface", async () => {
|
|
120
|
+
const tools = headlessTools(registry([
|
|
121
|
+
template("valuation", "pane-valuation", "VAL", { kind: "none" }),
|
|
122
|
+
template("fear", "pane-fear", "FNG", { kind: "none" }),
|
|
123
|
+
]));
|
|
124
|
+
const reversed = [...tools].reverse();
|
|
125
|
+
const changed = tools.map((tool, index) => index === 0
|
|
126
|
+
? { ...tool, description: `${tool.description} Changed.` }
|
|
127
|
+
: tool);
|
|
128
|
+
|
|
129
|
+
expect(await hashASKGToolManifests(tools)).toBe(await hashASKGToolManifests(reversed));
|
|
130
|
+
expect(await hashASKGToolManifests(changed)).not.toBe(await hashASKGToolManifests(tools));
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("skips illegal and ambiguous names instead of rewriting them", () => {
|
|
134
|
+
const catalog = registry([
|
|
135
|
+
// Short and digit leading tokens are legal: the terminal's own shortcuts
|
|
136
|
+
// include N, SI and 13F, and a tool the model cannot name is a tool the
|
|
137
|
+
// model cannot use.
|
|
138
|
+
template("short", "pane-short", "N", { kind: "none" }),
|
|
139
|
+
template("numeric", "pane-numeric", "13F", { kind: "none" }),
|
|
140
|
+
template("bad", "pane-bad", "BAD/TOKEN", { kind: "none" }),
|
|
141
|
+
template("first", "pane-first", "VAL", { kind: "none" }),
|
|
142
|
+
template("second", "pane-second", "val", { kind: "none" }),
|
|
143
|
+
]);
|
|
144
|
+
const { tools, skipped } = buildASKGToolManifests(catalog);
|
|
145
|
+
|
|
146
|
+
expect(tools.filter(({ source }) => source === "headless").map(({ name }) => name))
|
|
147
|
+
.toEqual(["13f", "n"]);
|
|
148
|
+
expect(skipped.map(({ token }) => token)).toEqual(["BAD/TOKEN", "VAL", "val"]);
|
|
149
|
+
expect(skipped.filter(({ token }) => token.toLowerCase() === "val").every(({ reason }) => (
|
|
150
|
+
reason.includes("Duplicate tool name")
|
|
151
|
+
))).toBe(true);
|
|
152
|
+
});
|
|
153
|
+
});
|