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
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

28 changes: 22 additions & 6 deletions sdk/typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,15 +26,23 @@ existing `agent-runtime::sdk` API.

## Repository usage

Build `bitfun-sdk-host`, then pass its absolute path while the platform-native
package layout is still pending:
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:

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

const apiKey = await trustedSecretStore.read("openai");
await using client = await AgentClient.start({
cwd: process.cwd(),
hostPath: process.env.BITFUN_SDK_HOST_PATH,
hostPath: "/absolute/path/to/bitfun-sdk-host",
model: {
provider: "openai",
model: "gpt-5.4",
apiKey,
baseUrl: "https://api.openai.com/v1",
},
});

await using query = await client.query({ prompt: "Summarize this repository" });
Expand All @@ -46,15 +54,23 @@ for await (const item of query) {
const result = await query.result();
```

`BITFUN_SDK_HOST_PATH` is also read directly when `hostPath` is omitted. The
eventual installable package must bundle or resolve a matching signed Host; it
must not require a separately installed BitFun CLI.
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.

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.

## Development

```bash
pnpm --dir sdk/typescript test
pnpm --dir sdk/typescript type-check
pnpm --dir sdk/typescript smoke:node
pnpm --dir sdk/typescript smoke:bun
```

The internal TypeScript wire bindings are generated from the Rust SDK Host
Expand Down
5 changes: 2 additions & 3 deletions sdk/typescript/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,6 @@
"private": true,
"description": "Internal TypeScript vertical slice for the BitFun Agent SDK",
"type": "module",
"engines": {
"node": ">=22.12.0"
},
"exports": {
".": {
"types": "./dist/sdk/typescript/src/index.d.ts",
Expand All @@ -23,6 +20,8 @@
"scripts": {
"build": "pnpm run generate:wire && tsc -p tsconfig.json",
"generate:wire": "node scripts/generate-wire.mjs",
"smoke:bun": "bun test/real-host-smoke.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 @@ -60,6 +60,8 @@ const requiredTypes = [
"SessionCreateParams",
"SessionCreateResult",
"ShutdownResult",
"TemporaryModelConfig",
"TemporaryModelProvider",
];
const missing = requiredTypes.filter((type) => !files.includes(type));
if (missing.length > 0) {
Expand Down
5 changes: 4 additions & 1 deletion 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: 1,
protocolVersion: 2,
runtimeVersion: "0.1.0",
stability: "not_delivered",
capabilities: {
Expand All @@ -61,8 +61,11 @@ test("Rust wire export produces executable validators for every type", async ()
mcpConfiguration: false,
prestartedTransport: false,
},
modelId: "sdk:openai:resolved",
};
assert.equal(validators.isInitializeResult(initializeResult), true);
const { modelId: _modelId, ...initializeResultWithoutModel } = initializeResult;
assert.equal(validators.isInitializeResult(initializeResultWithoutModel), false);
assert.equal(
validators.isInitializeResult({ ...initializeResult, unexpected: true }),
false,
Expand Down
99 changes: 85 additions & 14 deletions sdk/typescript/src/client.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
import { isAbsolute } from "node:path";

import type { InitializeResult, QueryStartParams, QueryStartResult } from "./internal/wire/index.js";
import type { JsonRpcConnection } from "./internal/json-rpc.js";
import { SdkError } from "./errors.js";
import { Query } from "./query.js";
import { Session, Sessions } from "./session.js";
import type { AgentCapabilities, AgentClientOptions, QueryInput } from "./types.js";
import type {
AgentCapabilities,
AgentClientOptions,
AgentModelOptions,
QueryInput,
} from "./types.js";

const SUPPORTED_MODEL_PROVIDERS = new Set([
"openai",
"responses",
"anthropic",
"gemini",
]);

export class AgentClient {
readonly #connection: JsonRpcConnection;
readonly #options: AgentClientOptions;
readonly #cwd: string;
readonly #modelId: string;
readonly capabilities: AgentCapabilities;
readonly sessions: Sessions;
readonly #queries = new Set<Query>();
Expand All @@ -15,14 +31,14 @@ export class AgentClient {
#closePromise?: Promise<void>;

static async start(options: AgentClientOptions): Promise<AgentClient> {
const hostPath = options.hostPath ?? process.env.BITFUN_SDK_HOST_PATH;
if (hostPath === undefined || hostPath.length === 0) {
const { SdkError } = await import("./errors.js");
throw new SdkError("SDK Host executable is unavailable", {
code: "not_found",
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_start",
correlationId: "local:host_validation",
outcomeCertainty: "not_started",
});
}
Expand All @@ -44,19 +60,21 @@ export class AgentClient {

private constructor(
connection: JsonRpcConnection,
options: AgentClientOptions,
options: Pick<AgentClientOptions, "cwd">,
initialized: InitializeResult,
) {
this.#connection = connection;
this.#options = options;
this.#cwd = options.cwd;
this.#modelId = initialized.modelId;
this.capabilities = Object.freeze({
query: initialized.capabilities.query,
sessions: initialized.capabilities.sessionCreate,
cancellation: initialized.capabilities.queryCancel,
});
this.sessions = Sessions.forClient(
connection,
options.cwd,
this.#cwd,
this.#modelId,
(query) => this.#trackQuery(query),
(session) => this.#trackSession(session),
() => this.#ensureOpen(),
Expand All @@ -66,7 +84,7 @@ export class AgentClient {
/** @internal */
static create(
connection: JsonRpcConnection,
options: AgentClientOptions,
options: Pick<AgentClientOptions, "cwd">,
initialized: InitializeResult,
): AgentClient {
return new AgentClient(connection, options, initialized);
Expand All @@ -79,8 +97,8 @@ export class AgentClient {
sessionId: null,
sessionName: null,
agent: input.agent ?? null,
cwd: this.#options.cwd,
model: input.model ?? null,
cwd: this.#cwd,
model: this.#modelId,
};
const started = await this.#connection.request<QueryStartResult>(
"query/start",
Expand Down Expand Up @@ -152,3 +170,56 @@ export class AgentClient {
session.onClosed(() => this.#ownedSessions.delete(session));
}
}

function validateModelOptions(value: unknown): asserts value is AgentModelOptions {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
invalidModel("model is required");
}
const model = value as Record<string, unknown>;
if (
typeof model.provider !== "string" ||
!SUPPORTED_MODEL_PROVIDERS.has(model.provider)
) {
invalidModel("model.provider is unsupported");
}
if (typeof model.model !== "string" || model.model.trim().length === 0) {
invalidModel("model.model is required");
}
if (typeof model.apiKey !== "string" || model.apiKey.trim().length === 0) {
invalidModel("model.apiKey is required");
}
if (model.baseUrl === undefined) {
return;
}
const invalidBaseUrl =
"model.baseUrl must be an absolute http or https URL without credentials, query, or fragment";
if (typeof model.baseUrl !== "string") {
invalidModel(invalidBaseUrl);
}
let url: URL;
try {
url = new URL(model.baseUrl);
} catch {
invalidModel(invalidBaseUrl);
}
if (
(url.protocol !== "http:" && url.protocol !== "https:") ||
url.hostname.length === 0 ||
url.username.length > 0 ||
url.password.length > 0 ||
url.search.length > 0 ||
url.hash.length > 0
) {
invalidModel(invalidBaseUrl);
}
}

function invalidModel(message: string): never {
throw new SdkError(message, {
code: "invalid_request",
stage: "initialize",
retryable: false,
correlationId: "local:model_validation",
outcomeCertainty: "not_started",
});
}
2 changes: 2 additions & 0 deletions sdk/typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ export { Session, Sessions } from "./session.js";
export type {
AgentCapabilities,
AgentClientOptions,
AgentModelOptions,
AgentModelProvider,
AssistantTextDelta,
OutcomeCertainty,
QueryInput,
Expand Down
10 changes: 8 additions & 2 deletions sdk/typescript/src/internal/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,24 @@ import { JsonRpcConnection } from "./json-rpc.js";
import type { HostTransport } from "./transport.js";
import type { InitializeParams, InitializeResult } from "./wire/index.js";

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

export async function createAgentClient(
transport: HostTransport,
options: AgentClientOptions,
options: Pick<AgentClientOptions, "cwd" | "initializeTimeoutMs" | "model">,
): Promise<AgentClient> {
const connection = new JsonRpcConnection(transport);
const params: InitializeParams = {
protocolVersion: PROTOCOL_VERSION,
clientInfo: { name: "@bitfun/agent-sdk", version: "0.0.0" },
capabilities: { serverNotifications: true },
model: {
provider: options.model.provider,
model: options.model.model,
apiKey: options.model.apiKey,
baseUrl: options.model.baseUrl,
},
};
const initialized = await connection.request<InitializeResult>(
"initialize",
Expand Down
7 changes: 5 additions & 2 deletions sdk/typescript/src/internal/wire-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,11 @@ function validateInitializeResult(value: unknown): InitializeResult {
value,
"initialize result",
);
if (!Number.isSafeInteger(result.protocolVersion)) {
throw new Error("SDK Host initialize protocol version is invalid");
if (
!Number.isSafeInteger(result.protocolVersion) ||
!isNonEmptyString(result.modelId)
) {
throw new Error("SDK Host initialize protocol version or model id is invalid");
}
return result;
}
Expand Down
7 changes: 6 additions & 1 deletion sdk/typescript/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import type { SessionCreateInput, SessionLifetime, TurnInput } from "./types.js"
export class Sessions {
readonly #connection: JsonRpcConnection;
readonly #cwd: string;
readonly #modelId: string;
readonly #onQuery: (query: Query) => Query;
readonly #onSession: (session: Session) => void;
readonly #ensureClientOpen: () => void;
Expand All @@ -22,13 +23,15 @@ export class Sessions {
static forClient(
connection: JsonRpcConnection,
cwd: string,
modelId: string,
onQuery: (query: Query) => Query,
onSession: (session: Session) => void,
ensureClientOpen: () => void,
): Sessions {
return new Sessions(
connection,
cwd,
modelId,
onQuery,
onSession,
ensureClientOpen,
Expand All @@ -38,12 +41,14 @@ export class Sessions {
private constructor(
connection: JsonRpcConnection,
cwd: string,
modelId: string,
onQuery: (query: Query) => Query,
onSession: (session: Session) => void,
ensureClientOpen: () => void,
) {
this.#connection = connection;
this.#cwd = cwd;
this.#modelId = modelId;
this.#onQuery = onQuery;
this.#onSession = onSession;
this.#ensureClientOpen = ensureClientOpen;
Expand All @@ -55,7 +60,7 @@ export class Sessions {
sessionName: input.sessionName ?? null,
agent: input.agent ?? null,
cwd: this.#cwd,
model: input.model ?? null,
model: this.#modelId,
};
const created = await this.#connection.request<SessionCreateResult>(
"session/create",
Expand Down
Loading