@smooai/smooth-extension-sdk 0.2.0 → 0.4.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.
- package/package.json +1 -1
- package/src/extension.ts +109 -5
- package/src/index.ts +7 -2
- package/src/protocol.ts +41 -0
- package/src/test-host.ts +23 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@smooai/smooth-extension-sdk",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "TypeScript SDK for building Smooth Extension Protocol (SEP) extensions: `defineExtension`, `defineTool`, a stdio JSON-RPC transport, an in-process test host, and a conformance runner. Extensions are subprocesses speaking JSON-RPC 2.0 ndjson to any SEP host (smooth-operator-core and its polyglot servers).",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
package/src/extension.ts
CHANGED
|
@@ -12,10 +12,67 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import { Peer } from './jsonrpc.js';
|
|
14
14
|
import { PROTOCOL_VERSION, method } from './protocol.js';
|
|
15
|
-
import type {
|
|
15
|
+
import type {
|
|
16
|
+
Context,
|
|
17
|
+
EventParams,
|
|
18
|
+
HookOutcome,
|
|
19
|
+
HookParams,
|
|
20
|
+
InitializeParams,
|
|
21
|
+
InitializeResult,
|
|
22
|
+
ToolExecuteParams,
|
|
23
|
+
ToolExecuteResult,
|
|
24
|
+
ToolUpdateParams,
|
|
25
|
+
UiKind,
|
|
26
|
+
UiRequestParams,
|
|
27
|
+
UiRequestResult,
|
|
28
|
+
} from './protocol.js';
|
|
16
29
|
import { toJsonSchema, type ParameterSchema } from './schema.js';
|
|
17
30
|
import { stdioTransport, type Transport } from './transport.js';
|
|
18
31
|
|
|
32
|
+
/** The intercept hooks (awaited, host-orchestrated); every other `on(...)` name
|
|
33
|
+
* is a fire-and-forget observe event. Kept in sync with the engine's HookType. */
|
|
34
|
+
const HOOK_NAMES = new Set(['tool_call', 'tool_result', 'before_agent_start', 'message_end', 'context', 'before_provider_request', 'input', 'user_bash']);
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The `ui/request` surface handed to tools (and to event handlers via
|
|
38
|
+
* `smooth.ui`). Each call is an ext→host request; the frontend renders it and
|
|
39
|
+
* replies. `select`/`confirm`/`input` return an answer (or `{ cancelled: true }`
|
|
40
|
+
* if dismissed); `notify`/`setStatus`/`setWidget`/`setTitle` resolve empty. A
|
|
41
|
+
* headless or uncapable host rejects with an `RpcError` of code -32001 (NoUI) —
|
|
42
|
+
* gate with `hasUI(kind)` to avoid it.
|
|
43
|
+
*/
|
|
44
|
+
export interface UiApi {
|
|
45
|
+
select(prompt: string, options: string[]): Promise<UiRequestResult>;
|
|
46
|
+
confirm(prompt: string): Promise<UiRequestResult>;
|
|
47
|
+
input(prompt: string, opts?: { default?: string }): Promise<UiRequestResult>;
|
|
48
|
+
notify(message: string, level?: 'info' | 'warn' | 'error'): Promise<void>;
|
|
49
|
+
setStatus(status: string): Promise<void>;
|
|
50
|
+
setWidget(widget: Record<string, unknown>): Promise<void>;
|
|
51
|
+
setTitle(title: string): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Build a [`UiApi`] that speaks `ui/request` over `peer`. */
|
|
55
|
+
function makeUi(peer: Peer): UiApi {
|
|
56
|
+
const req = (params: UiRequestParams) => peer.request<UiRequestResult>(method.UI_REQUEST, params);
|
|
57
|
+
return {
|
|
58
|
+
select: (prompt, options) => req({ kind: 'select', prompt, options }),
|
|
59
|
+
confirm: (prompt) => req({ kind: 'confirm', prompt }),
|
|
60
|
+
input: (prompt, opts) => req({ kind: 'input', prompt, ...(opts?.default !== undefined ? { default: opts.default } : {}) }),
|
|
61
|
+
notify: async (message, level) => {
|
|
62
|
+
await req({ kind: 'notify', message, ...(level ? { level } : {}) });
|
|
63
|
+
},
|
|
64
|
+
setStatus: async (status) => {
|
|
65
|
+
await req({ kind: 'set_status', status });
|
|
66
|
+
},
|
|
67
|
+
setWidget: async (widget) => {
|
|
68
|
+
await req({ kind: 'set_widget', widget });
|
|
69
|
+
},
|
|
70
|
+
setTitle: async (title) => {
|
|
71
|
+
await req({ kind: 'set_title', title });
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
19
76
|
/** Progress + cancellation handed to a tool while it runs. */
|
|
20
77
|
export interface ToolContext {
|
|
21
78
|
/** Correlates `onUpdate` calls with this execution. */
|
|
@@ -26,6 +83,10 @@ export interface ToolContext {
|
|
|
26
83
|
signal: AbortSignal;
|
|
27
84
|
/** Stream a progress notification back to the host. */
|
|
28
85
|
onUpdate(update: Omit<ToolUpdateParams, 'call_id'>): void;
|
|
86
|
+
/** Ask the frontend to render a dialog/widget. See [`UiApi`]. */
|
|
87
|
+
ui: UiApi;
|
|
88
|
+
/** True if the host's frontend can render this `ui/request` kind. */
|
|
89
|
+
hasUI(kind: UiKind): boolean;
|
|
29
90
|
}
|
|
30
91
|
|
|
31
92
|
/** What a tool's `execute` may return: a full result or just its `content`. */
|
|
@@ -44,8 +105,14 @@ export function defineTool<TArgs = Record<string, unknown>>(def: ToolDef<TArgs>)
|
|
|
44
105
|
return def;
|
|
45
106
|
}
|
|
46
107
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
108
|
+
/** A hook handler's friendly return: veto the operation, or replace its input
|
|
109
|
+
* with a patch (shallow-merged onto the input). Returning nothing = continue. */
|
|
110
|
+
export type HookResult = { block: true; reason?: string } | { patch: Record<string, unknown> };
|
|
111
|
+
|
|
112
|
+
/** Handler for an `on(name, ...)` registration. For an observe event the return
|
|
113
|
+
* is ignored; for an intercept hook (see {@link HOOK_NAMES}) return a
|
|
114
|
+
* {@link HookResult} to veto or patch. Mirrors pi's single `on`. */
|
|
115
|
+
export type EventHandler = (data: Record<string, unknown> | undefined, ctx: Context) => void | HookResult | Promise<void | HookResult>;
|
|
49
116
|
|
|
50
117
|
/** The builder passed to `defineExtension`'s setup. Mirrors pi's `ExtensionAPI`. */
|
|
51
118
|
export interface SmoothApi {
|
|
@@ -54,6 +121,12 @@ export interface SmoothApi {
|
|
|
54
121
|
registerTool(tool: ToolDef<any>): void;
|
|
55
122
|
on(event: string, handler: EventHandler): void;
|
|
56
123
|
log(level: 'debug' | 'info' | 'warn' | 'error', message: string, fields?: Record<string, unknown>): void;
|
|
124
|
+
/** True if the host's frontend can render this `ui/request` kind. Only
|
|
125
|
+
* meaningful after `initialize` — returns false before the handshake. */
|
|
126
|
+
hasUI(kind: UiKind): boolean;
|
|
127
|
+
/** The `ui/request` surface. Available after the extension connects; throws
|
|
128
|
+
* if read before then. Gate with [`hasUI`]. */
|
|
129
|
+
readonly ui: UiApi;
|
|
57
130
|
}
|
|
58
131
|
|
|
59
132
|
export type ExtensionSetup = (smooth: SmoothApi) => void;
|
|
@@ -70,6 +143,8 @@ export class Extension {
|
|
|
70
143
|
private version = '0.0.0';
|
|
71
144
|
/** Set once connected so `log()` before connect is a safe no-op. */
|
|
72
145
|
private live?: Peer;
|
|
146
|
+
/** UI kinds the host declared answerable at `initialize`. */
|
|
147
|
+
private hostUiCaps: string[] = [];
|
|
73
148
|
|
|
74
149
|
constructor(setup: ExtensionSetup) {
|
|
75
150
|
const api: SmoothApi = {
|
|
@@ -96,6 +171,11 @@ export class Extension {
|
|
|
96
171
|
log: (level, message, fields) => {
|
|
97
172
|
this.live?.notify(method.LOG, { level, message, ...(fields ? { fields } : {}) });
|
|
98
173
|
},
|
|
174
|
+
hasUI: (kind) => self.hostUiCaps.includes(kind),
|
|
175
|
+
get ui() {
|
|
176
|
+
if (!self.live) throw new Error('smooth.ui is only available after the extension connects');
|
|
177
|
+
return makeUi(self.live);
|
|
178
|
+
},
|
|
99
179
|
};
|
|
100
180
|
// `self` alias so the getter/setter pair above closes over the instance.
|
|
101
181
|
const self = this;
|
|
@@ -114,6 +194,7 @@ export class Extension {
|
|
|
114
194
|
return {};
|
|
115
195
|
});
|
|
116
196
|
peer.setRequestHandler(method.TOOL_EXECUTE, (params, signal) => this.executeTool(params as ToolExecuteParams, peer, signal));
|
|
197
|
+
peer.setRequestHandler(method.HOOK, (params) => this.dispatchHook(params as HookParams));
|
|
117
198
|
peer.setNotificationHandler(method.EVENT, (params) => this.dispatchEvent(params as EventParams));
|
|
118
199
|
|
|
119
200
|
transport.start((frame) => peer.receive(frame));
|
|
@@ -134,17 +215,21 @@ export class Extension {
|
|
|
134
215
|
});
|
|
135
216
|
}
|
|
136
217
|
|
|
137
|
-
private initialize(
|
|
218
|
+
private initialize(params: InitializeParams): InitializeResult {
|
|
219
|
+
this.hostUiCaps = params.ui_capabilities ?? [];
|
|
138
220
|
const tools = [...this.tools.values()].map((t) => ({
|
|
139
221
|
name: t.name,
|
|
140
222
|
description: t.description,
|
|
141
223
|
parameters: toJsonSchema(t.parameters),
|
|
142
224
|
...(t.deferred ? { deferred: true } : {}),
|
|
143
225
|
}));
|
|
226
|
+
// Only observe events go in `subscriptions` — hook names are intercepts
|
|
227
|
+
// the host always calls, not events it filters by subscription.
|
|
228
|
+
const subscriptions = [...this.events.keys()].filter((name) => !HOOK_NAMES.has(name));
|
|
144
229
|
return {
|
|
145
230
|
protocol_version: PROTOCOL_VERSION,
|
|
146
231
|
extension: { name: this.name, version: this.version },
|
|
147
|
-
registrations: { tools, subscriptions
|
|
232
|
+
registrations: { tools, subscriptions },
|
|
148
233
|
};
|
|
149
234
|
}
|
|
150
235
|
|
|
@@ -156,6 +241,8 @@ export class Extension {
|
|
|
156
241
|
context: params.context,
|
|
157
242
|
signal,
|
|
158
243
|
onUpdate: (update) => peer.notify(method.TOOL_UPDATE, { call_id: params.call_id, ...update }),
|
|
244
|
+
ui: makeUi(peer),
|
|
245
|
+
hasUI: (kind) => this.hostUiCaps.includes(kind),
|
|
159
246
|
};
|
|
160
247
|
const out = await tool.execute(params.arguments, ctx);
|
|
161
248
|
return typeof out === 'string' ? { content: out } : out;
|
|
@@ -166,6 +253,23 @@ export class Extension {
|
|
|
166
253
|
void handler(params.payload, params.context);
|
|
167
254
|
}
|
|
168
255
|
}
|
|
256
|
+
|
|
257
|
+
/** Fold this extension's handlers for one `hook` into a single outcome: the
|
|
258
|
+
* first `block` short-circuits; `patch`es shallow-merge onto the input and
|
|
259
|
+
* thread to the next handler; no result = continue. The host chains the
|
|
260
|
+
* outcome across extensions in load order. */
|
|
261
|
+
private async dispatchHook(params: HookParams): Promise<HookOutcome> {
|
|
262
|
+
let input = params.input;
|
|
263
|
+
let modified = false;
|
|
264
|
+
for (const handler of this.events.get(params.hook) ?? []) {
|
|
265
|
+
const result = await handler(input, params.context);
|
|
266
|
+
if (!result) continue;
|
|
267
|
+
if ('block' in result) return { action: 'block', ...(result.reason ? { reason: result.reason } : {}) };
|
|
268
|
+
input = { ...input, ...result.patch };
|
|
269
|
+
modified = true;
|
|
270
|
+
}
|
|
271
|
+
return modified ? { action: 'modify', patch: input } : { action: 'continue' };
|
|
272
|
+
}
|
|
169
273
|
}
|
|
170
274
|
|
|
171
275
|
/** Define an extension. Set `smooth.name`/`smooth.version` and register tools. */
|
package/src/index.ts
CHANGED
|
@@ -9,9 +9,9 @@
|
|
|
9
9
|
* `runConformance`.
|
|
10
10
|
*/
|
|
11
11
|
export { defineExtension, defineTool, Extension } from './extension.js';
|
|
12
|
-
export type { ExtensionSetup, SmoothApi, ToolDef, ToolContext, ToolReturn, EventHandler, ConnectHandle } from './extension.js';
|
|
12
|
+
export type { ExtensionSetup, SmoothApi, ToolDef, ToolContext, ToolReturn, EventHandler, HookResult, ConnectHandle, UiApi } from './extension.js';
|
|
13
13
|
export { createTestHost } from './test-host.js';
|
|
14
|
-
export type { TestHost, CallToolOptions } from './test-host.js';
|
|
14
|
+
export type { TestHost, CallToolOptions, CreateTestHostOptions, UiResponder } from './test-host.js';
|
|
15
15
|
export { runConformance, DEFAULT_SPEC_DIR } from './conformance.js';
|
|
16
16
|
export type { ConformanceReport, ConformanceStep, RunConformanceOptions } from './conformance.js';
|
|
17
17
|
export { toJsonSchema } from './schema.js';
|
|
@@ -31,4 +31,9 @@ export type {
|
|
|
31
31
|
ToolExecuteResult,
|
|
32
32
|
ToolUpdateParams,
|
|
33
33
|
EventParams,
|
|
34
|
+
HookParams,
|
|
35
|
+
HookOutcome,
|
|
36
|
+
UiKind,
|
|
37
|
+
UiRequestParams,
|
|
38
|
+
UiRequestResult,
|
|
34
39
|
} from './protocol.js';
|
package/src/protocol.ts
CHANGED
|
@@ -18,6 +18,7 @@ export const method = {
|
|
|
18
18
|
HOOK: 'hook',
|
|
19
19
|
TOOL_EXECUTE: 'tool/execute',
|
|
20
20
|
TOOL_UPDATE: 'tool/update',
|
|
21
|
+
UI_REQUEST: 'ui/request',
|
|
21
22
|
REGISTRY_UPDATE: 'registry/update',
|
|
22
23
|
LOG: 'log',
|
|
23
24
|
CANCEL: '$/cancel',
|
|
@@ -112,6 +113,46 @@ export interface ToolUpdateParams {
|
|
|
112
113
|
|
|
113
114
|
export interface EventParams {
|
|
114
115
|
event: string;
|
|
116
|
+
/** Per-connection monotonic sequence; absent on the `events_lost` marker. */
|
|
117
|
+
seq?: number;
|
|
115
118
|
context: Context;
|
|
116
119
|
payload?: Record<string, unknown>;
|
|
117
120
|
}
|
|
121
|
+
|
|
122
|
+
/** Host → ext `hook` request: an awaited intercept the extension can veto/patch. */
|
|
123
|
+
export interface HookParams {
|
|
124
|
+
hook: string;
|
|
125
|
+
context: Context;
|
|
126
|
+
input: Record<string, unknown>;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The extension's reply to a `hook`, tagged by `action`. */
|
|
130
|
+
export type HookOutcome =
|
|
131
|
+
| { action: 'continue' }
|
|
132
|
+
| { action: 'block'; reason?: string }
|
|
133
|
+
| { action: 'modify'; patch: Record<string, unknown> };
|
|
134
|
+
|
|
135
|
+
/** The seven `ui/request` kinds (snake_case wire names). */
|
|
136
|
+
export type UiKind = 'select' | 'confirm' | 'input' | 'notify' | 'set_status' | 'set_widget' | 'set_title';
|
|
137
|
+
|
|
138
|
+
/** Params of `ui/request` (ext → host), discriminated by `kind`. */
|
|
139
|
+
export type UiRequestParams =
|
|
140
|
+
| { kind: 'select'; prompt: string; options: string[] }
|
|
141
|
+
| { kind: 'confirm'; prompt: string }
|
|
142
|
+
| { kind: 'input'; prompt: string; default?: string }
|
|
143
|
+
| { kind: 'notify'; message: string; level?: 'info' | 'warn' | 'error' }
|
|
144
|
+
| { kind: 'set_status'; status: string }
|
|
145
|
+
| { kind: 'set_widget'; widget: Record<string, unknown> }
|
|
146
|
+
| { kind: 'set_title'; title: string };
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Reply to a `ui/request`. Which field is set depends on the request `kind`:
|
|
150
|
+
* `select` → `value`, `confirm` → `confirmed`, `input` → `text`; the rest are
|
|
151
|
+
* empty. Any kind may set `cancelled` if the user dismissed the UI.
|
|
152
|
+
*/
|
|
153
|
+
export interface UiRequestResult {
|
|
154
|
+
value?: string;
|
|
155
|
+
confirmed?: boolean;
|
|
156
|
+
text?: string;
|
|
157
|
+
cancelled?: boolean;
|
|
158
|
+
}
|
package/src/test-host.ts
CHANGED
|
@@ -5,14 +5,23 @@
|
|
|
5
5
|
* (with progress + cancellation), events, ping and shutdown directly against a
|
|
6
6
|
* `defineExtension(...)` object.
|
|
7
7
|
*/
|
|
8
|
-
import { Peer } from './jsonrpc.js';
|
|
9
|
-
import { PROTOCOL_VERSION, method } from './protocol.js';
|
|
10
|
-
import type { Context, InitializeParams, InitializeResult, ToolExecuteResult, ToolUpdateParams } from './protocol.js';
|
|
8
|
+
import { Peer, RpcError } from './jsonrpc.js';
|
|
9
|
+
import { PROTOCOL_VERSION, errorCode, method } from './protocol.js';
|
|
10
|
+
import type { Context, HookOutcome, InitializeParams, InitializeResult, ToolExecuteResult, ToolUpdateParams, UiRequestParams, UiRequestResult } from './protocol.js';
|
|
11
11
|
import type { Extension } from './extension.js';
|
|
12
12
|
import { linkedPair } from './transport.js';
|
|
13
13
|
|
|
14
14
|
let callSeq = 0;
|
|
15
15
|
|
|
16
|
+
/** Answers the extension's `ui/request` calls. Return a result, or throw an
|
|
17
|
+
* `RpcError` (e.g. code -32001) to simulate a headless/uncapable frontend. */
|
|
18
|
+
export type UiResponder = (params: UiRequestParams) => UiRequestResult | Promise<UiRequestResult>;
|
|
19
|
+
|
|
20
|
+
export interface CreateTestHostOptions {
|
|
21
|
+
/** Answers `ui/request`. Default: reject every call with -32001 NoUI. */
|
|
22
|
+
onUiRequest?: UiResponder;
|
|
23
|
+
}
|
|
24
|
+
|
|
16
25
|
export interface CallToolOptions {
|
|
17
26
|
/** Receives each `tool/update` the extension streams for this call. */
|
|
18
27
|
onUpdate?: (update: ToolUpdateParams) => void;
|
|
@@ -25,6 +34,8 @@ export interface CallToolOptions {
|
|
|
25
34
|
export interface TestHost {
|
|
26
35
|
initialize(overrides?: Partial<InitializeParams>): Promise<InitializeResult>;
|
|
27
36
|
callTool(tool: string, args: Record<string, unknown>, opts?: CallToolOptions): Promise<ToolExecuteResult>;
|
|
37
|
+
/** Drive a `hook` request and get back the extension's folded outcome. */
|
|
38
|
+
callHook(hook: string, input: Record<string, unknown>, context?: Context): Promise<HookOutcome>;
|
|
28
39
|
ping(): Promise<Record<string, unknown>>;
|
|
29
40
|
sendEvent(event: string, payload?: Record<string, unknown>, context?: Context): void;
|
|
30
41
|
shutdown(): Promise<void>;
|
|
@@ -33,7 +44,7 @@ export interface TestHost {
|
|
|
33
44
|
|
|
34
45
|
const DEFAULT_CONTEXT: Context = { token: 'test-epoch', tier: 'command' };
|
|
35
46
|
|
|
36
|
-
export function createTestHost(extension: Extension): TestHost {
|
|
47
|
+
export function createTestHost(extension: Extension, options: CreateTestHostOptions = {}): TestHost {
|
|
37
48
|
const [hostT, extT] = linkedPair();
|
|
38
49
|
const extHandle = extension.connect(extT);
|
|
39
50
|
/** call_id → the caller's onUpdate, so streamed progress reaches the test. */
|
|
@@ -44,6 +55,11 @@ export function createTestHost(extension: Extension): TestHost {
|
|
|
44
55
|
const p = params as ToolUpdateParams;
|
|
45
56
|
updateSinks.get(p.call_id)?.(p);
|
|
46
57
|
});
|
|
58
|
+
// Answer ext→host `ui/request`. Default mimics a headless frontend (NoUI).
|
|
59
|
+
host.setRequestHandler(method.UI_REQUEST, async (params) => {
|
|
60
|
+
if (!options.onUiRequest) throw new RpcError(errorCode.NoUI, 'no UI available (headless test host)');
|
|
61
|
+
return options.onUiRequest(params as UiRequestParams);
|
|
62
|
+
});
|
|
47
63
|
// Extension notifications the host just observes in tests.
|
|
48
64
|
host.setNotificationHandler(method.LOG, () => {});
|
|
49
65
|
host.setNotificationHandler(method.REGISTRY_UPDATE, () => {});
|
|
@@ -74,6 +90,9 @@ export function createTestHost(extension: Extension): TestHost {
|
|
|
74
90
|
updateSinks.delete(call_id);
|
|
75
91
|
}
|
|
76
92
|
},
|
|
93
|
+
callHook(hook, input, context) {
|
|
94
|
+
return host.request<HookOutcome>(method.HOOK, { hook, input, context: context ?? DEFAULT_CONTEXT });
|
|
95
|
+
},
|
|
77
96
|
ping() {
|
|
78
97
|
return host.request<Record<string, unknown>>(method.PING, {});
|
|
79
98
|
},
|