Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions .changeset/sep-phase2-sdk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
---
"@smooai/smooth-extension-sdk": minor
"@smooai/smooth-operator": patch
---

SEP Phase 2 (SDK + spec) — hooks + the observe event bus.

`@smooai/smooth-extension-sdk` gains **hook handlers**: `smooth.on(name, handler)`
now covers both observe events (return ignored) and intercept hooks (return a
`HookResult` — `{ block, reason? }` to veto or `{ patch }` to rewrite the input).
The extension answers the `hook` request by folding its own handlers in
registration order (first `block` short-circuits; `patch`es shallow-merge and
thread to the next), and the host chains the outcome across extensions. Hook
names are kept out of the reported event `subscriptions`. `createTestHost` gains
`callHook(hook, input)`; new `permission-gate` demo extension blocks dangerous
`bash` commands via a fail-closed `tool_call` hook.

`spec/extension`: the event schema gains an optional `seq` (per-connection
monotonic sequence; absent on the out-of-band `events_lost` marker) with a
`model_select → AgentEvent::ModelResolved` parity note, and fixtures add a
seq-numbered event, the `events_lost` marker (drop-N → count), a
`tool_execution_start` event, and the `tool_result` hook input + a result-shaped
`modify` outcome. Rust and TypeScript conformance replays stay green.
42 changes: 40 additions & 2 deletions spec/extension/conformance/fixtures.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,33 @@

"event_params": {
"$schema_ref": "methods/event.schema.json#/$defs/Params",
"description": "A turn_start lifecycle event delivered to a subscribed extension.",
"description": "A turn_start lifecycle event delivered to a subscribed extension, carrying its per-connection sequence number.",
"instance": {
"event": "turn_start",
"seq": 41,
"context": { "token": "epoch-7", "tier": "event" },
"payload": { "turn_id": "turn-42" }
"payload": { "agent_id": "agent-1" }
}
},

"event_tool_execution_start": {
"$schema_ref": "methods/event.schema.json#/$defs/Params",
"description": "A tool_execution_start observe event (pi-aligned name), seq-numbered.",
"instance": {
"event": "tool_execution_start",
"seq": 42,
"context": { "token": "epoch-7", "tier": "event" },
"payload": { "iteration": 1, "tool": "bash" }
}
},

"event_events_lost": {
"$schema_ref": "methods/event.schema.json#/$defs/Params",
"description": "The out-of-band events_lost marker delivered after the bounded observe queue shed 12 oldest events. It carries the shed count and a context but NO seq — the gap in the surrounding seq run is itself the loss signal.",
"instance": {
"event": "events_lost",
"context": { "token": "epoch-7", "tier": "event" },
"payload": { "lost": 12 }
}
},

Expand Down Expand Up @@ -98,6 +120,22 @@
"instance": { "action": "modify", "patch": { "tool": "bash", "arguments": { "command": "echo blocked" } } }
},

"hook_params_tool_result": {
"$schema_ref": "methods/hook.schema.json#/$defs/Params",
"description": "A tool_result intercept (fail-open): the completed result is offered to the extension before it is pushed to the conversation.",
"instance": {
"hook": "tool_result",
"context": { "token": "epoch-7", "tier": "command" },
"input": { "tool": "bash", "arguments": { "command": "ls" }, "content": "total 0\n", "is_error": false }
}
},

"hook_outcome_modify_tool_result": {
"$schema_ref": "methods/hook.schema.json#/$defs/HookOutcome",
"description": "A tool_result modify that rewrites the content the agent sees (e.g. redaction).",
"instance": { "action": "modify", "patch": { "content": "[redacted]", "is_error": false } }
},

"tool_execute_params": {
"$schema_ref": "methods/tool-execute.schema.json#/$defs/Params",
"description": "Dispatch of the echo extension's `say` tool.",
Expand Down
9 changes: 7 additions & 2 deletions spec/extension/methods/event.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,12 @@
"properties": {
"event": {
"type": "string",
"description": "Event name, e.g. `turn_start`, `turn_end`, `message_end`, `tool_call_update`, `session_start`, `events_lost`."
"description": "Event name. Names mirror pi's for near-mechanical porting: `agent_start`/`agent_end`, `turn_start`/`turn_end`, `message_start`/`message_update`/`message_end`, `tool_execution_start`/`tool_execution_update`/`tool_execution_end`, `model_select` (the engine emits this as `AgentEvent::ModelResolved`), `session_start`/`session_compact`/`session_shutdown`/`session_tree`. The out-of-band `events_lost` marker is delivered after shedding."
},
"seq": {
"type": "integer",
"minimum": 0,
"description": "Per-connection monotonic sequence number. A gap in the run signals that the bounded observe queue shed events; the following `events_lost` marker carries the exact count. Absent on the `events_lost` marker itself (it is out-of-band)."
},
"context": {
"type": "object",
Expand All @@ -24,7 +29,7 @@
"tier": { "type": "string", "enum": ["event", "command"] }
}
},
"payload": { "type": "object", "description": "Event-specific data; extensions read what they understand." }
"payload": { "type": "object", "description": "Event-specific data; extensions read what they understand. For `events_lost`: `{ \"lost\": <count> }`." }
}
}
},
Expand Down
37 changes: 37 additions & 0 deletions typescript/extension-sdk/examples/permission-gate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**
* `permission-gate` — the Phase 2 demo extension. It vetoes dangerous `bash`
* commands via the fail-closed `tool_call` hook before the tool ever runs.
*
* Run it as a real SEP subprocess: `tsx examples/permission-gate.ts`
* The host runs this hook before executing any tool; a `block` stops the call.
* Because `tool_call` is fail-closed, if this process hangs or crashes the host
* times out and blocks the tool anyway — safe by default.
*/
import { defineExtension } from '../src/index.js';

/** Commands that should never run unattended. Add patterns as needed. */
const DANGEROUS: RegExp[] = [
/\brm\s+-[a-z]*[rf]/, // rm -rf / rm -fr / rm -r ...
/\bmkfs\b/, // format a filesystem
/\bdd\s+.*\bof=\/dev\//, // dd onto a raw device
/>\s*\/dev\/sd[a-z]/, // redirect onto a raw disk
/:\(\)\s*\{.*:\|:.*\}\s*;\s*:/, // classic fork bomb
/\bchmod\s+-R\s+0*777\s+\//, // chmod -R 777 /
];

export const permissionGate = defineExtension((smooth) => {
smooth.name = 'permission-gate';
smooth.version = '0.1.0';

smooth.on('tool_call', (input) => {
if (input?.tool !== 'bash') return;
const command = String((input.arguments as Record<string, unknown> | undefined)?.command ?? '');
const hit = DANGEROUS.find((re) => re.test(command));
if (hit) return { block: true, reason: `blocked dangerous command (matched ${hit})` };
});
});

// When run directly (not imported by a test), serve over stdio.
if (import.meta.url === `file://${process.argv[1]}`) {
permissionGate.serve();
}
39 changes: 35 additions & 4 deletions typescript/extension-sdk/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,14 @@
*/
import { Peer } from './jsonrpc.js';
import { PROTOCOL_VERSION, method } from './protocol.js';
import type { Context, EventParams, InitializeParams, InitializeResult, ToolExecuteParams, ToolExecuteResult, ToolUpdateParams } from './protocol.js';
import type { Context, EventParams, HookOutcome, HookParams, InitializeParams, InitializeResult, ToolExecuteParams, ToolExecuteResult, ToolUpdateParams } from './protocol.js';
import { toJsonSchema, type ParameterSchema } from './schema.js';
import { stdioTransport, type Transport } from './transport.js';

/** The intercept hooks (awaited, host-orchestrated); every other `on(...)` name
* is a fire-and-forget observe event. Kept in sync with the engine's HookType. */
const HOOK_NAMES = new Set(['tool_call', 'tool_result', 'before_agent_start', 'message_end', 'context', 'before_provider_request', 'input', 'user_bash']);

/** Progress + cancellation handed to a tool while it runs. */
export interface ToolContext {
/** Correlates `onUpdate` calls with this execution. */
Expand Down Expand Up @@ -44,8 +48,14 @@ export function defineTool<TArgs = Record<string, unknown>>(def: ToolDef<TArgs>)
return def;
}

/** Handler for an observe `event`. Fire-and-forget; return value ignored. */
export type EventHandler = (payload: Record<string, unknown> | undefined, ctx: Context) => void | Promise<void>;
/** A hook handler's friendly return: veto the operation, or replace its input
* with a patch (shallow-merged onto the input). Returning nothing = continue. */
export type HookResult = { block: true; reason?: string } | { patch: Record<string, unknown> };

/** Handler for an `on(name, ...)` registration. For an observe event the return
* is ignored; for an intercept hook (see {@link HOOK_NAMES}) return a
* {@link HookResult} to veto or patch. Mirrors pi's single `on`. */
export type EventHandler = (data: Record<string, unknown> | undefined, ctx: Context) => void | HookResult | Promise<void | HookResult>;

/** The builder passed to `defineExtension`'s setup. Mirrors pi's `ExtensionAPI`. */
export interface SmoothApi {
Expand Down Expand Up @@ -114,6 +124,7 @@ export class Extension {
return {};
});
peer.setRequestHandler(method.TOOL_EXECUTE, (params, signal) => this.executeTool(params as ToolExecuteParams, peer, signal));
peer.setRequestHandler(method.HOOK, (params) => this.dispatchHook(params as HookParams));
peer.setNotificationHandler(method.EVENT, (params) => this.dispatchEvent(params as EventParams));

transport.start((frame) => peer.receive(frame));
Expand Down Expand Up @@ -141,10 +152,13 @@ export class Extension {
parameters: toJsonSchema(t.parameters),
...(t.deferred ? { deferred: true } : {}),
}));
// Only observe events go in `subscriptions` — hook names are intercepts
// the host always calls, not events it filters by subscription.
const subscriptions = [...this.events.keys()].filter((name) => !HOOK_NAMES.has(name));
return {
protocol_version: PROTOCOL_VERSION,
extension: { name: this.name, version: this.version },
registrations: { tools, subscriptions: [...this.events.keys()] },
registrations: { tools, subscriptions },
};
}

Expand All @@ -166,6 +180,23 @@ export class Extension {
void handler(params.payload, params.context);
}
}

/** Fold this extension's handlers for one `hook` into a single outcome: the
* first `block` short-circuits; `patch`es shallow-merge onto the input and
* thread to the next handler; no result = continue. The host chains the
* outcome across extensions in load order. */
private async dispatchHook(params: HookParams): Promise<HookOutcome> {
let input = params.input;
let modified = false;
for (const handler of this.events.get(params.hook) ?? []) {
const result = await handler(input, params.context);
if (!result) continue;
if ('block' in result) return { action: 'block', ...(result.reason ? { reason: result.reason } : {}) };
input = { ...input, ...result.patch };
modified = true;
}
return modified ? { action: 'modify', patch: input } : { action: 'continue' };
}
}

/** Define an extension. Set `smooth.name`/`smooth.version` and register tools. */
Expand Down
4 changes: 3 additions & 1 deletion typescript/extension-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
* `runConformance`.
*/
export { defineExtension, defineTool, Extension } from './extension.js';
export type { ExtensionSetup, SmoothApi, ToolDef, ToolContext, ToolReturn, EventHandler, ConnectHandle } from './extension.js';
export type { ExtensionSetup, SmoothApi, ToolDef, ToolContext, ToolReturn, EventHandler, HookResult, ConnectHandle } from './extension.js';
export { createTestHost } from './test-host.js';
export type { TestHost, CallToolOptions } from './test-host.js';
export { runConformance, DEFAULT_SPEC_DIR } from './conformance.js';
Expand All @@ -31,4 +31,6 @@ export type {
ToolExecuteResult,
ToolUpdateParams,
EventParams,
HookParams,
HookOutcome,
} from './protocol.js';
15 changes: 15 additions & 0 deletions typescript/extension-sdk/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,21 @@ export interface ToolUpdateParams {

export interface EventParams {
event: string;
/** Per-connection monotonic sequence; absent on the `events_lost` marker. */
seq?: number;
context: Context;
payload?: Record<string, unknown>;
}

/** Host → ext `hook` request: an awaited intercept the extension can veto/patch. */
export interface HookParams {
hook: string;
context: Context;
input: Record<string, unknown>;
}

/** The extension's reply to a `hook`, tagged by `action`. */
export type HookOutcome =
| { action: 'continue' }
| { action: 'block'; reason?: string }
| { action: 'modify'; patch: Record<string, unknown> };
7 changes: 6 additions & 1 deletion typescript/extension-sdk/src/test-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
*/
import { Peer } from './jsonrpc.js';
import { PROTOCOL_VERSION, method } from './protocol.js';
import type { Context, InitializeParams, InitializeResult, ToolExecuteResult, ToolUpdateParams } from './protocol.js';
import type { Context, HookOutcome, InitializeParams, InitializeResult, ToolExecuteResult, ToolUpdateParams } from './protocol.js';
import type { Extension } from './extension.js';
import { linkedPair } from './transport.js';

Expand All @@ -25,6 +25,8 @@ export interface CallToolOptions {
export interface TestHost {
initialize(overrides?: Partial<InitializeParams>): Promise<InitializeResult>;
callTool(tool: string, args: Record<string, unknown>, opts?: CallToolOptions): Promise<ToolExecuteResult>;
/** Drive a `hook` request and get back the extension's folded outcome. */
callHook(hook: string, input: Record<string, unknown>, context?: Context): Promise<HookOutcome>;
ping(): Promise<Record<string, unknown>>;
sendEvent(event: string, payload?: Record<string, unknown>, context?: Context): void;
shutdown(): Promise<void>;
Expand Down Expand Up @@ -74,6 +76,9 @@ export function createTestHost(extension: Extension): TestHost {
updateSinks.delete(call_id);
}
},
callHook(hook, input, context) {
return host.request<HookOutcome>(method.HOOK, { hook, input, context: context ?? DEFAULT_CONTEXT });
},
ping() {
return host.request<Record<string, unknown>>(method.PING, {});
},
Expand Down
54 changes: 54 additions & 0 deletions typescript/extension-sdk/test/hook.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { describe, expect, it } from 'vitest';
import { createTestHost, defineExtension } from '../src/index.js';
import { permissionGate } from '../examples/permission-gate.js';

describe('hook handlers', () => {
it('permission-gate blocks a dangerous bash command via tool_call', async () => {
const host = createTestHost(permissionGate);
await host.initialize();

const blocked = await host.callHook('tool_call', { tool: 'bash', arguments: { command: 'rm -rf /' } });
expect(blocked.action).toBe('block');

const ok = await host.callHook('tool_call', { tool: 'bash', arguments: { command: 'ls -la' } });
expect(ok.action).toBe('continue');
host.close();
});

it('a patch outcome shallow-merges onto the input', async () => {
const ext = defineExtension((smooth) => {
smooth.name = 'redactor';
smooth.on('tool_result', () => ({ patch: { content: '[redacted]' } }));
});
const host = createTestHost(ext);
await host.initialize();

const outcome = await host.callHook('tool_result', { tool: 'bash', content: 'secret', is_error: false });
expect(outcome).toEqual({ action: 'modify', patch: { tool: 'bash', content: '[redacted]', is_error: false } });
host.close();
});

it('first block short-circuits later handlers', async () => {
const ext = defineExtension((smooth) => {
smooth.name = 'multi';
smooth.on('tool_call', () => ({ block: true, reason: 'first' }));
smooth.on('tool_call', () => ({ patch: { should: 'not apply' } }));
});
const host = createTestHost(ext);
await host.initialize();
expect(await host.callHook('tool_call', { tool: 'bash' })).toEqual({ action: 'block', reason: 'first' });
host.close();
});

it('hook names are not reported as event subscriptions', async () => {
const ext = defineExtension((smooth) => {
smooth.name = 'mixed';
smooth.on('tool_call', () => undefined);
smooth.on('turn_start', () => undefined);
});
const host = createTestHost(ext);
const init = await host.initialize();
expect(init.registrations?.subscriptions).toEqual(['turn_start']);
host.close();
});
});
Loading