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
55 changes: 42 additions & 13 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ The slice validates the intended public object model:
same connection and existing Agent Runtime owner;
- `Query` is an ordered async stream with idempotent cancellation, cached final
`Result`, and explicit close semantics;
- the same stream reports safe Tool lifecycle facts and permission requests;
`Query.respondPermission()` supports allow once, allow always, or reject;
- protocol and process failures use `SdkError`, including outcome certainty.

Lifecycle cleanup is bounded. The Windows Host contains descendants in a
Expand All @@ -26,17 +28,28 @@ existing `agent-runtime::sdk` API.

## Repository usage

Build `bitfun-sdk-host`, then pass its absolute path and one process-lifetime
model configuration while the platform-native package layout is still pending.
The Host path must be explicit in this slice:
Build the private SDK and `bitfun-sdk-host`, then stage that already-built Host
into the local package. This does not install BitFun or publish anything:

```bash
cargo build -p bitfun-sdk-host-app
pnpm --dir sdk/typescript build
pnpm --dir sdk/typescript stage:host -- ../../target/debug/bitfun-sdk-host.exe
```

Use `bitfun-sdk-host` without `.exe` on macOS and Linux. The staging command
copies only the current platform's executable into the package build under
`dist/sdk/typescript/native/<platform>-<arch>/`.

The trusted application then supplies one process-lifetime model configuration;
the SDK finds and manages the staged native Host automatically:

```typescript
import { AgentClient } from "@bitfun/agent-sdk";

const apiKey = await trustedSecretStore.read("openai");
await using client = await AgentClient.start({
cwd: process.cwd(),
hostPath: "/absolute/path/to/bitfun-sdk-host",
model: {
provider: "openai",
model: "gpt-5.4",
Expand All @@ -47,22 +60,37 @@ await using client = await AgentClient.start({

await using query = await client.query({ prompt: "Summarize this repository" });
for await (const item of query) {
if (item.type === "assistant_text_delta") {
process.stdout.write(item.text);
switch (item.type) {
case "assistant_text_delta":
process.stdout.write(item.text);
break;
case "tool_event":
console.log(item.toolName, item.status);
break;
case "permission_request":
await query.respondPermission(item.requestId, { decision: "allow_once" });
break;
}
}
const result = await query.result();
```

This repository-local package is private and unpublished. Node 24.14.1 and Bun
1.4.0 are the locally verified runners for this slice; they are not bundled
executables or a final minimum-version policy. The eventual installable package
must bundle or resolve a matching signed Host; it must not require a separately
installed BitFun CLI.
An explicit absolute `hostPath` remains available as a development override.
The SDK never searches `PATH` or an environment variable for the Host.

This repository-local package is private and unpublished. Node 24.14.1 is
locally verified for this slice. Bun uses the same ESM build but remains a
release-verification target when a Bun runner is available; neither runtime is
a bundled executable or a final minimum-version policy. `pnpm --dir sdk/typescript pack`
can produce a local tarball containing the staged Host. An application installs
that tarball as an ordinary dependency; it does not install BitFun or a CLI
separately. This PR does not publish the package. A future registry release
still needs platform packages, signing, and release verification.

Browser and mobile runtimes cannot launch the local native Host. Custom
functions, permission and user-input callbacks, structured output, usage,
Session resume, Python support, and native package staging remain deferred.
functions, general user-input callbacks, structured output, usage, Session
resume, Python support, platform package publication, signing, and downloads
remain deferred.

## Development

Expand All @@ -71,6 +99,7 @@ pnpm --dir sdk/typescript test
pnpm --dir sdk/typescript type-check
pnpm --dir sdk/typescript smoke:node
pnpm --dir sdk/typescript smoke:bun
pnpm --dir sdk/typescript smoke:consumer
```

The internal TypeScript wire bindings are generated from the Rust SDK Host
Expand Down
3 changes: 3 additions & 0 deletions sdk/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,16 @@
"dist/sdk/typescript/src/*.d.ts",
"dist/sdk/typescript/src/*.js",
"dist/sdk/typescript/src/internal/*.js",
"dist/sdk/typescript/native/**",
"dist/src/crates/adapters/transport/typescript/src/*.js",
"README.md"
],
"scripts": {
"build": "pnpm run generate:wire && tsc -p tsconfig.json",
"generate:wire": "node scripts/generate-wire.mjs",
"stage:host": "node scripts/stage-host.mjs",
"smoke:bun": "bun test/real-host-smoke.mjs",
"smoke:consumer": "node test/local-package-consumer.mjs",
"smoke:node": "node test/real-host-smoke.mjs",
"test": "pnpm run build && node --test scripts/*.test.mjs dist/src/crates/adapters/transport/typescript/test/**/*.test.js dist/sdk/typescript/test/**/*.test.js",
"type-check": "pnpm run generate:wire && tsc -p tsconfig.json --noEmit"
Expand Down
2 changes: 2 additions & 0 deletions sdk/typescript/scripts/generate-wire.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ const requiredTypes = [
"HostCapabilities",
"InitializeParams",
"InitializeResult",
"PermissionRespondParams",
"PermissionRespondResult",
"QueryCancelParams",
"QueryCancelResult",
"QueryEventParams",
Expand Down
5 changes: 3 additions & 2 deletions sdk/typescript/scripts/generated-wire-runtime.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ test("Rust wire export produces executable validators for every type", async ()
}

const initializeResult = {
protocolVersion: 2,
protocolVersion: 3,
runtimeVersion: "0.1.0",
stability: "not_delivered",
capabilities: {
Expand All @@ -53,10 +53,11 @@ test("Rust wire export produces executable validators for every type", async ()
queryCancel: true,
sessionClose: true,
eventStream: true,
toolEvents: true,
structuredOutput: false,
usage: false,
customTools: false,
permissionCallbacks: false,
permissionResponses: true,
hooks: false,
mcpConfiguration: false,
prestartedTransport: false,
Expand Down
47 changes: 47 additions & 0 deletions sdk/typescript/scripts/stage-host.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { chmod, copyFile, mkdir, stat } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { pathToFileURL } from "node:url";

export async function stageHost(source, destination) {
let sourceMetadata;
try {
sourceMetadata = await stat(source);
} catch (cause) {
throw new Error(`Host source was not found: ${source}`, { cause });
}
if (!sourceMetadata.isFile()) {
throw new Error(`Host source must be a file: ${source}`);
}

await mkdir(dirname(destination), { recursive: true });
await copyFile(source, destination);
if (process.platform !== "win32") {
await chmod(destination, 0o755);
}
}

async function main() {
const [source, ...extra] = process.argv.slice(2);
if (source === undefined || extra.length > 0) {
throw new Error("Usage: pnpm stage:host -- <host-executable>");
}

const { packageHostPath } = await import(
"../dist/sdk/typescript/src/internal/host-path.js"
);
const destination = packageHostPath(process.platform, process.arch);
await stageHost(resolve(source), destination);
process.stdout.write(`Staged BitFun SDK Host at ${destination}\n`);
}

if (
process.argv[1] !== undefined &&
import.meta.url === pathToFileURL(process.argv[1]).href
) {
try {
await main();
} catch (error) {
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
process.exitCode = 1;
}
}
53 changes: 53 additions & 0 deletions sdk/typescript/scripts/stage-host.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import assert from "node:assert/strict";
import { mkdtemp, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import test from "node:test";

test("stageHost copies an already-built Host into its package destination", async () => {
const stageHost = await loadStageHost();
const root = await mkdtemp(join(tmpdir(), "bitfun-sdk-stage-host-"));
const source = join(root, "source-host");
const destination = join(root, "package", "native", "host");
const contents = Buffer.from("local-host-fixture\n", "utf8");
try {
await writeFile(source, contents, { mode: 0o600 });

await stageHost(source, destination);

assert.deepEqual(await readFile(destination), contents);
if (process.platform !== "win32") {
assert.notEqual((await stat(destination)).mode & 0o111, 0);
}
} finally {
await rm(root, { recursive: true, force: true });
}
});

test("stageHost rejects a directory source", async () => {
const stageHost = await loadStageHost();
const root = await mkdtemp(join(tmpdir(), "bitfun-sdk-stage-host-invalid-"));
try {
const source = join(root, "source-directory");
await mkdir(source);
await assert.rejects(
stageHost(source, join(root, "destination")),
/Host source must be a file/,
);
} finally {
await rm(root, { recursive: true, force: true });
}
});

async function loadStageHost() {
try {
const module = await import("./stage-host.mjs");
assert.equal(typeof module.stageHost, "function");
return module.stageHost;
} catch (error) {
if (error?.code === "ERR_MODULE_NOT_FOUND") {
assert.fail("stage-host.mjs must export stageHost");
}
throw error;
}
}
22 changes: 10 additions & 12 deletions sdk/typescript/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { isAbsolute } from "node:path";

import type { InitializeResult, QueryStartParams, QueryStartResult } from "./internal/wire/index.js";
import type { JsonRpcConnection } from "./internal/json-rpc.js";
import { resolveHostPath } from "./internal/host-path.js";
import { SdkError } from "./errors.js";
import { Query } from "./query.js";
import { Session, Sessions } from "./session.js";
Expand Down Expand Up @@ -32,16 +31,7 @@ export class AgentClient {

static async start(options: AgentClientOptions): Promise<AgentClient> {
validateModelOptions(options.model as unknown);
const hostPath = options.hostPath;
if (typeof hostPath !== "string" || !isAbsolute(hostPath)) {
throw new SdkError("SDK Host path must be an explicit absolute path", {
code: "invalid_request",
stage: "initialize",
retryable: false,
correlationId: "local:host_validation",
outcomeCertainty: "not_started",
});
}
const hostPath = resolveHostPath(options.hostPath);
const [{ createAgentClient }, { startManagedHost }] = await Promise.all([
import("./internal/client.js"),
import("./internal/managed-host.js"),
Expand Down Expand Up @@ -70,6 +60,14 @@ export class AgentClient {
query: initialized.capabilities.query,
sessions: initialized.capabilities.sessionCreate,
cancellation: initialized.capabilities.queryCancel,
eventStream: initialized.capabilities.eventStream,
toolEvents: initialized.capabilities.toolEvents,
permissionResponses: initialized.capabilities.permissionResponses,
structuredOutput: initialized.capabilities.structuredOutput,
usage: initialized.capabilities.usage,
customTools: initialized.capabilities.customTools,
hooks: initialized.capabilities.hooks,
mcpConfiguration: initialized.capabilities.mcpConfiguration,
});
this.sessions = Sessions.forClient(
connection,
Expand Down
5 changes: 5 additions & 0 deletions sdk/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ export type {
AgentModelProvider,
AssistantTextDelta,
OutcomeCertainty,
PermissionDecision,
PermissionRequestEvent,
PermissionResponse,
PermissionSource,
QueryInput,
QueryStreamItem,
RecoveryAction,
Expand All @@ -22,4 +26,5 @@ export type {
SessionLifetime,
Turn,
TurnInput,
ToolEvent,
} from "./types.js";
7 changes: 5 additions & 2 deletions sdk/typescript/src/internal/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { JsonRpcConnection } from "./json-rpc.js";
import type { HostTransport } from "./transport.js";
import type { InitializeParams, InitializeResult } from "./wire/index.js";

const PROTOCOL_VERSION = 2;
const PROTOCOL_VERSION = 3;
const DEFAULT_INITIALIZE_TIMEOUT_MS = 30_000;

export async function createAgentClient(
Expand All @@ -15,7 +15,10 @@ export async function createAgentClient(
const params: InitializeParams = {
protocolVersion: PROTOCOL_VERSION,
clientInfo: { name: "@bitfun/agent-sdk", version: "0.0.0" },
capabilities: { serverNotifications: true },
capabilities: {
serverNotifications: true,
permissionResponses: true,
},
model: {
provider: options.model.provider,
model: options.model.model,
Expand Down
30 changes: 30 additions & 0 deletions sdk/typescript/src/internal/host-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { isAbsolute } from "node:path";
import { fileURLToPath } from "node:url";

import { SdkError } from "../errors.js";

export function resolveHostPath(explicitPath?: string): string {
if (explicitPath === undefined) {
return packageHostPath(process.platform, process.arch);
}
if (typeof explicitPath !== "string" || !isAbsolute(explicitPath)) {
throw new SdkError("SDK Host path must be an explicit absolute path", {
code: "invalid_request",
stage: "initialize",
retryable: false,
correlationId: "local:host_validation",
outcomeCertainty: "not_started",
});
}
return explicitPath;
}

export function packageHostPath(
platform: NodeJS.Platform,
arch: NodeJS.Architecture,
): string {
const executable = platform === "win32" ? "bitfun-sdk-host.exe" : "bitfun-sdk-host";
return fileURLToPath(
new URL(`../../native/${platform}-${arch}/${executable}`, import.meta.url),
);
}
Loading