@hostler/sdk 0.1.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/LICENSE +21 -0
- package/README.md +45 -0
- package/dist/client.d.ts +44 -0
- package/dist/client.js +64 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/session.d.ts +94 -0
- package/dist/session.js +342 -0
- package/dist/sse.d.ts +15 -0
- package/dist/sse.js +46 -0
- package/dist/transport.d.ts +29 -0
- package/dist/transport.js +74 -0
- package/dist/types.d.ts +215 -0
- package/dist/types.js +9 -0
- package/package.json +31 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ankit Gupta
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# @hostler/sdk
|
|
2
|
+
|
|
3
|
+
TypeScript SDK for the [Hostler](https://hostler.dev) API: managed agents,
|
|
4
|
+
sessions, live event streams, and client-tool execution.
|
|
5
|
+
|
|
6
|
+
Full documentation: **https://hostler.dev/docs/sdk**
|
|
7
|
+
|
|
8
|
+
```ts
|
|
9
|
+
import { Hostler } from "@hostler/sdk";
|
|
10
|
+
|
|
11
|
+
const hostler = new Hostler({ apiKey: process.env.HOSTLER_KEY! });
|
|
12
|
+
|
|
13
|
+
const agent = await hostler.agents.create({
|
|
14
|
+
name: "inventory-assistant",
|
|
15
|
+
harness: "pi",
|
|
16
|
+
model: { provider: "anthropic", id: "claude-haiku-4-5" },
|
|
17
|
+
system: "You are a warehouse assistant. Use get_inventory for stock questions.",
|
|
18
|
+
clientTools: [
|
|
19
|
+
{
|
|
20
|
+
name: "get_inventory",
|
|
21
|
+
description: "Look up current warehouse stock for an item.",
|
|
22
|
+
inputSchema: { type: "object", properties: { item: { type: "string" } }, required: ["item"] },
|
|
23
|
+
},
|
|
24
|
+
],
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
const session = await hostler.sessions.create({ agentId: agent.id });
|
|
28
|
+
|
|
29
|
+
// One call: sends the message, streams events, executes your client tools
|
|
30
|
+
// when the agent asks for them, resolves when the turn completes.
|
|
31
|
+
const { text } = await session.run("How many widgets are in stock?", {
|
|
32
|
+
tools: {
|
|
33
|
+
get_inventory: async (input) => ({ item: input, count: 42 }),
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
console.log(text);
|
|
37
|
+
|
|
38
|
+
await session.terminate();
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Lower-level surface: `session.send()`, `session.stream()` (SSE with automatic
|
|
42
|
+
reconnect and replay), `session.submitToolResult()`, `session.confirmTool()`,
|
|
43
|
+
`session.outputFiles()`.
|
|
44
|
+
|
|
45
|
+
Requires Node 18+ (or any runtime with `fetch`). Zero dependencies.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/** The Hostler client: `new Hostler({ apiKey })` and the resource surfaces. */
|
|
2
|
+
import { Transport } from "./transport.ts";
|
|
3
|
+
import { Session } from "./session.ts";
|
|
4
|
+
import type { Agent, AgentConfig, CreateSessionOptions, SessionInfo } from "./types.ts";
|
|
5
|
+
export interface HostlerOptions {
|
|
6
|
+
/**
|
|
7
|
+
* A team API key (`cpk_...`) minted in the console's Settings page.
|
|
8
|
+
* Typed to accept `process.env.X` directly; a missing value throws at
|
|
9
|
+
* construction rather than on the first request.
|
|
10
|
+
*/
|
|
11
|
+
apiKey: string | undefined;
|
|
12
|
+
/** API origin. Defaults to https://hostler.dev; self-hosted deployments pass their own. */
|
|
13
|
+
baseUrl?: string;
|
|
14
|
+
/** Custom fetch implementation (tests, instrumentation). Defaults to the global. */
|
|
15
|
+
fetch?: typeof fetch;
|
|
16
|
+
}
|
|
17
|
+
export declare const DEFAULT_BASE_URL = "https://hostler.dev";
|
|
18
|
+
export declare class Hostler {
|
|
19
|
+
readonly agents: AgentsResource;
|
|
20
|
+
readonly sessions: SessionsResource;
|
|
21
|
+
constructor(options: HostlerOptions);
|
|
22
|
+
}
|
|
23
|
+
export declare class AgentsResource {
|
|
24
|
+
#private;
|
|
25
|
+
constructor(transport: Transport);
|
|
26
|
+
/** Register an agent (version 1). */
|
|
27
|
+
create(config: AgentConfig): Promise<Agent>;
|
|
28
|
+
list(): Promise<Agent[]>;
|
|
29
|
+
/** Latest version, or a specific one via `version`. */
|
|
30
|
+
get(id: string, options?: {
|
|
31
|
+
version?: number;
|
|
32
|
+
}): Promise<Agent>;
|
|
33
|
+
/** Append a new immutable version (existing sessions keep the one they pinned). */
|
|
34
|
+
createVersion(id: string, config: AgentConfig): Promise<Agent>;
|
|
35
|
+
}
|
|
36
|
+
export declare class SessionsResource {
|
|
37
|
+
#private;
|
|
38
|
+
constructor(transport: Transport);
|
|
39
|
+
/** Launch a sandbox running the agent and return a handle to drive it. */
|
|
40
|
+
create(options: CreateSessionOptions): Promise<Session>;
|
|
41
|
+
list(): Promise<SessionInfo[]>;
|
|
42
|
+
/** Handle to an existing session (verifies it exists; 404 throws). */
|
|
43
|
+
get(id: string): Promise<Session>;
|
|
44
|
+
}
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/** The Hostler client: `new Hostler({ apiKey })` and the resource surfaces. */
|
|
2
|
+
import { Transport } from "./transport.js";
|
|
3
|
+
import { Session } from "./session.js";
|
|
4
|
+
export const DEFAULT_BASE_URL = "https://hostler.dev";
|
|
5
|
+
export class Hostler {
|
|
6
|
+
agents;
|
|
7
|
+
sessions;
|
|
8
|
+
constructor(options) {
|
|
9
|
+
const apiKey = options.apiKey;
|
|
10
|
+
if (apiKey === undefined || apiKey === "")
|
|
11
|
+
throw new Error("Hostler: apiKey is required");
|
|
12
|
+
const transport = new Transport({
|
|
13
|
+
apiKey,
|
|
14
|
+
baseUrl: options.baseUrl ?? DEFAULT_BASE_URL,
|
|
15
|
+
fetch: options.fetch ?? ((...args) => fetch(...args)),
|
|
16
|
+
});
|
|
17
|
+
this.agents = new AgentsResource(transport);
|
|
18
|
+
this.sessions = new SessionsResource(transport);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export class AgentsResource {
|
|
22
|
+
#transport;
|
|
23
|
+
constructor(transport) {
|
|
24
|
+
this.#transport = transport;
|
|
25
|
+
}
|
|
26
|
+
/** Register an agent (version 1). */
|
|
27
|
+
create(config) {
|
|
28
|
+
return this.#transport.request("POST", "/v1/agents", config);
|
|
29
|
+
}
|
|
30
|
+
async list() {
|
|
31
|
+
const body = await this.#transport.request("GET", "/v1/agents");
|
|
32
|
+
return body.agents;
|
|
33
|
+
}
|
|
34
|
+
/** Latest version, or a specific one via `version`. */
|
|
35
|
+
get(id, options = {}) {
|
|
36
|
+
const query = options.version !== undefined ? `?version=${options.version}` : "";
|
|
37
|
+
return this.#transport.request("GET", `/v1/agents/${id}${query}`);
|
|
38
|
+
}
|
|
39
|
+
/** Append a new immutable version (existing sessions keep the one they pinned). */
|
|
40
|
+
createVersion(id, config) {
|
|
41
|
+
return this.#transport.request("POST", `/v1/agents/${id}/versions`, config);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export class SessionsResource {
|
|
45
|
+
#transport;
|
|
46
|
+
constructor(transport) {
|
|
47
|
+
this.#transport = transport;
|
|
48
|
+
}
|
|
49
|
+
/** Launch a sandbox running the agent and return a handle to drive it. */
|
|
50
|
+
async create(options) {
|
|
51
|
+
const row = await this.#transport.request("POST", "/v1/sessions", options);
|
|
52
|
+
return new Session(this.#transport, row.id);
|
|
53
|
+
}
|
|
54
|
+
list() {
|
|
55
|
+
return this.#transport
|
|
56
|
+
.request("GET", "/v1/sessions")
|
|
57
|
+
.then((body) => body.sessions);
|
|
58
|
+
}
|
|
59
|
+
/** Handle to an existing session (verifies it exists; 404 throws). */
|
|
60
|
+
async get(id) {
|
|
61
|
+
await this.#transport.request("GET", `/v1/sessions/${id}`);
|
|
62
|
+
return new Session(this.#transport, id);
|
|
63
|
+
}
|
|
64
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { Hostler, AgentsResource, SessionsResource, DEFAULT_BASE_URL, type HostlerOptions } from "./client.ts";
|
|
2
|
+
export { Session, type ConfirmDecision, type RunOptions, type RunResult, type StreamOptions, type ToolHandler, } from "./session.ts";
|
|
3
|
+
export { HostlerError } from "./transport.ts";
|
|
4
|
+
export type { Agent, AgentConfig, ClientToolDefinition, CreateSessionOptions, ModelConfig, OutputFile, SessionEvent, SessionInfo, SessionResourceMount, SessionStatus, StopReason, ToolConfirmation, ToolResult, ToolResultContent, } from "./types.ts";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session handle: send messages, stream the event log, answer client
|
|
3
|
+
* tools, and drive whole turns with `run()`.
|
|
4
|
+
*/
|
|
5
|
+
import { type Transport } from "./transport.ts";
|
|
6
|
+
import type { OutputFile, SessionEvent, SessionInfo, StopReason, ToolConfirmation, ToolResult } from "./types.ts";
|
|
7
|
+
export interface StreamOptions {
|
|
8
|
+
/** Replay events with seq greater than this, then go live. Defaults to 0 (full history). */
|
|
9
|
+
since?: number;
|
|
10
|
+
/** Ends the stream (the generator returns cleanly) when aborted. */
|
|
11
|
+
signal?: AbortSignal;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Executes one client-tool call in your process. Return a string (used
|
|
15
|
+
* verbatim) or any JSON-serializable value (stringified). Throwing produces
|
|
16
|
+
* an error tool-result; the thrown message is shown to the model verbatim.
|
|
17
|
+
*/
|
|
18
|
+
export type ToolHandler = (input: unknown, call: {
|
|
19
|
+
toolCallId: string;
|
|
20
|
+
name: string;
|
|
21
|
+
}) => unknown;
|
|
22
|
+
export type ConfirmDecision = "allow" | "deny" | {
|
|
23
|
+
result: "allow" | "deny";
|
|
24
|
+
denyMessage?: string;
|
|
25
|
+
};
|
|
26
|
+
export interface RunOptions {
|
|
27
|
+
/**
|
|
28
|
+
* Handlers for the agent's client tools, keyed by tool name. Every parked
|
|
29
|
+
* client-tool call is answered from this map; a call with no handler gets
|
|
30
|
+
* an error result so the turn can complete. Use `stream()` instead if you
|
|
31
|
+
* want to observe without answering.
|
|
32
|
+
*/
|
|
33
|
+
tools?: Record<string, ToolHandler>;
|
|
34
|
+
/**
|
|
35
|
+
* Decides approval-gated (`always_ask`) tool calls. When omitted, such
|
|
36
|
+
* calls stay parked — e.g. for an operator to approve in the console — and
|
|
37
|
+
* the run waits for that decision.
|
|
38
|
+
*/
|
|
39
|
+
onConfirm?: (call: {
|
|
40
|
+
toolCallId: string;
|
|
41
|
+
name: string;
|
|
42
|
+
input: unknown;
|
|
43
|
+
}) => ConfirmDecision | Promise<ConfirmDecision>;
|
|
44
|
+
/** Observe every event as the run streams it. */
|
|
45
|
+
onEvent?: (event: SessionEvent) => void;
|
|
46
|
+
/** Aborting throws HostlerError("run aborted") from run(). */
|
|
47
|
+
signal?: AbortSignal;
|
|
48
|
+
}
|
|
49
|
+
export interface RunResult {
|
|
50
|
+
/** The agent's final message this turn, or null if it produced none. */
|
|
51
|
+
text: string | null;
|
|
52
|
+
stopReason: StopReason;
|
|
53
|
+
}
|
|
54
|
+
export declare class Session {
|
|
55
|
+
#private;
|
|
56
|
+
readonly id: string;
|
|
57
|
+
constructor(transport: Transport, id: string);
|
|
58
|
+
info(): Promise<SessionInfo>;
|
|
59
|
+
/** Queue a user message; the turn runs asynchronously (watch `stream()`). */
|
|
60
|
+
send(text: string, options?: {
|
|
61
|
+
signal?: AbortSignal;
|
|
62
|
+
}): Promise<void>;
|
|
63
|
+
/** Abort the current turn; the session settles to idle. */
|
|
64
|
+
interrupt(): Promise<void>;
|
|
65
|
+
/** Tear down the sandbox. Output files are captured first and stay downloadable. */
|
|
66
|
+
terminate(): Promise<SessionInfo>;
|
|
67
|
+
/** One-shot JSON read of the event log (no streaming). */
|
|
68
|
+
events(options?: {
|
|
69
|
+
since?: number;
|
|
70
|
+
}): Promise<SessionEvent[]>;
|
|
71
|
+
/** Answer a parked client-tool call. Exactly one of content/error. */
|
|
72
|
+
submitToolResult(result: ToolResult, options?: {
|
|
73
|
+
signal?: AbortSignal;
|
|
74
|
+
}): Promise<void>;
|
|
75
|
+
/** Allow or deny an approval-gated tool call. */
|
|
76
|
+
confirmTool(confirmation: ToolConfirmation, options?: {
|
|
77
|
+
signal?: AbortSignal;
|
|
78
|
+
}): Promise<void>;
|
|
79
|
+
outputFiles(): Promise<OutputFile[]>;
|
|
80
|
+
readOutputFile(path: string): Promise<Uint8Array>;
|
|
81
|
+
/**
|
|
82
|
+
* Live event stream: replays history past `since`, then follows the session
|
|
83
|
+
* until it terminates. Reconnects automatically (resuming from the last
|
|
84
|
+
* seen seq) when the connection drops; HTTP errors (401/404/...) throw.
|
|
85
|
+
*/
|
|
86
|
+
stream(options?: StreamOptions): AsyncGenerator<SessionEvent, void, void>;
|
|
87
|
+
/**
|
|
88
|
+
* Send a message and drive the turn to completion: streams events, answers
|
|
89
|
+
* parked client-tool calls from `tools`, routes approval requests to
|
|
90
|
+
* `onConfirm`, and resolves when the session settles (end_turn, interrupted,
|
|
91
|
+
* or error). Throws if the session terminates mid-run or `signal` aborts.
|
|
92
|
+
*/
|
|
93
|
+
run(text: string, options?: RunOptions): Promise<RunResult>;
|
|
94
|
+
}
|
package/dist/session.js
ADDED
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The session handle: send messages, stream the event log, answer client
|
|
3
|
+
* tools, and drive whole turns with `run()`.
|
|
4
|
+
*/
|
|
5
|
+
import { HostlerError } from "./transport.js";
|
|
6
|
+
import { SseParser } from "./sse.js";
|
|
7
|
+
const INITIAL_RECONNECT_MS = 500;
|
|
8
|
+
const MAX_RECONNECT_MS = 10_000;
|
|
9
|
+
export class Session {
|
|
10
|
+
id;
|
|
11
|
+
#transport;
|
|
12
|
+
constructor(transport, id) {
|
|
13
|
+
this.#transport = transport;
|
|
14
|
+
this.id = id;
|
|
15
|
+
}
|
|
16
|
+
info() {
|
|
17
|
+
return this.#transport.request("GET", `/v1/sessions/${this.id}`);
|
|
18
|
+
}
|
|
19
|
+
/** Queue a user message; the turn runs asynchronously (watch `stream()`). */
|
|
20
|
+
async send(text, options = {}) {
|
|
21
|
+
await this.#transport.request("POST", `/v1/sessions/${this.id}/messages`, { text }, options);
|
|
22
|
+
}
|
|
23
|
+
/** Abort the current turn; the session settles to idle. */
|
|
24
|
+
async interrupt() {
|
|
25
|
+
await this.#transport.request("POST", `/v1/sessions/${this.id}/interrupt`, undefined);
|
|
26
|
+
}
|
|
27
|
+
/** Tear down the sandbox. Output files are captured first and stay downloadable. */
|
|
28
|
+
terminate() {
|
|
29
|
+
return this.#transport.request("DELETE", `/v1/sessions/${this.id}`);
|
|
30
|
+
}
|
|
31
|
+
/** One-shot JSON read of the event log (no streaming). */
|
|
32
|
+
async events(options = {}) {
|
|
33
|
+
const since = options.since ?? 0;
|
|
34
|
+
const body = await this.#transport.request("GET", `/v1/sessions/${this.id}/events?since=${since}`);
|
|
35
|
+
return body.events;
|
|
36
|
+
}
|
|
37
|
+
/** Answer a parked client-tool call. Exactly one of content/error. */
|
|
38
|
+
async submitToolResult(result, options = {}) {
|
|
39
|
+
await this.#transport.request("POST", `/v1/sessions/${this.id}/tool_results`, result, options);
|
|
40
|
+
}
|
|
41
|
+
/** Allow or deny an approval-gated tool call. */
|
|
42
|
+
async confirmTool(confirmation, options = {}) {
|
|
43
|
+
await this.#transport.request("POST", `/v1/sessions/${this.id}/tool_confirmations`, confirmation, options);
|
|
44
|
+
}
|
|
45
|
+
async outputFiles() {
|
|
46
|
+
const body = await this.#transport.request("GET", `/v1/sessions/${this.id}/output_files`);
|
|
47
|
+
return body.files;
|
|
48
|
+
}
|
|
49
|
+
async readOutputFile(path) {
|
|
50
|
+
const res = await this.#transport.requestRaw(`/v1/sessions/${this.id}/output_files/${path.split("/").map(encodeURIComponent).join("/")}`);
|
|
51
|
+
return new Uint8Array(await res.arrayBuffer());
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Live event stream: replays history past `since`, then follows the session
|
|
55
|
+
* until it terminates. Reconnects automatically (resuming from the last
|
|
56
|
+
* seen seq) when the connection drops; HTTP errors (401/404/...) throw.
|
|
57
|
+
*/
|
|
58
|
+
async *stream(options = {}) {
|
|
59
|
+
let since = options.since ?? 0;
|
|
60
|
+
let backoffMs = INITIAL_RECONNECT_MS;
|
|
61
|
+
const external = options.signal;
|
|
62
|
+
while (true) {
|
|
63
|
+
if (external?.aborted)
|
|
64
|
+
return;
|
|
65
|
+
// One controller per connection so returning from the generator (or the
|
|
66
|
+
// caller aborting) tears the fetch down instead of leaking the socket.
|
|
67
|
+
const connection = new AbortController();
|
|
68
|
+
const onExternalAbort = () => connection.abort();
|
|
69
|
+
external?.addEventListener("abort", onExternalAbort, { once: true });
|
|
70
|
+
try {
|
|
71
|
+
let res;
|
|
72
|
+
try {
|
|
73
|
+
res = await this.#transport.requestRaw(`/v1/sessions/${this.id}/events?since=${since}`, {
|
|
74
|
+
accept: "text/event-stream",
|
|
75
|
+
signal: connection.signal,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch (e) {
|
|
79
|
+
if (external?.aborted)
|
|
80
|
+
return;
|
|
81
|
+
// 4xx means the request itself is wrong (bad key, unknown session) —
|
|
82
|
+
// retrying can't help. 5xx and network failures are transient (e.g.
|
|
83
|
+
// the ALB while the control plane restarts) — retry with backoff.
|
|
84
|
+
if (e instanceof HostlerError && e.status >= 400 && e.status < 500)
|
|
85
|
+
throw e;
|
|
86
|
+
await sleep(backoffMs, external);
|
|
87
|
+
backoffMs = Math.min(backoffMs * 2, MAX_RECONNECT_MS);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (res.body === null)
|
|
91
|
+
throw new HostlerError("event stream had no body", 0);
|
|
92
|
+
const reader = res.body.getReader();
|
|
93
|
+
const decoder = new TextDecoder();
|
|
94
|
+
const parser = new SseParser();
|
|
95
|
+
let sawEvent = false;
|
|
96
|
+
try {
|
|
97
|
+
while (true) {
|
|
98
|
+
const { done, value } = await reader.read();
|
|
99
|
+
if (done)
|
|
100
|
+
break;
|
|
101
|
+
for (const frame of parser.push(decoder.decode(value, { stream: true }))) {
|
|
102
|
+
const event = JSON.parse(frame.data);
|
|
103
|
+
if (event.seq <= since)
|
|
104
|
+
continue; // reconnect replay overlap
|
|
105
|
+
since = event.seq;
|
|
106
|
+
sawEvent = true;
|
|
107
|
+
backoffMs = INITIAL_RECONNECT_MS; // healthy connection: reset
|
|
108
|
+
yield event;
|
|
109
|
+
if (event.type === "session.status_terminated")
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
catch (e) {
|
|
115
|
+
if (external?.aborted)
|
|
116
|
+
return;
|
|
117
|
+
// Mid-stream read failure: fall through to reconnect below.
|
|
118
|
+
void e;
|
|
119
|
+
}
|
|
120
|
+
if (external?.aborted)
|
|
121
|
+
return;
|
|
122
|
+
// A clean close with zero events can mean the session terminated with
|
|
123
|
+
// our cursor already past the terminal event (the server replays
|
|
124
|
+
// nothing and just closes) — reconnecting would loop forever.
|
|
125
|
+
if (!sawEvent) {
|
|
126
|
+
try {
|
|
127
|
+
const info = await this.info();
|
|
128
|
+
if (info.status === "terminated")
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
// The probe can hit the same restarting control plane that just
|
|
133
|
+
// closed the stream — only a 4xx is conclusive; anything else
|
|
134
|
+
// falls through to the normal reconnect backoff.
|
|
135
|
+
if (e instanceof HostlerError && e.status >= 400 && e.status < 500)
|
|
136
|
+
throw e;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
// Server closed without a terminal event (e.g. a deploy) — reconnect.
|
|
140
|
+
await sleep(backoffMs, external);
|
|
141
|
+
backoffMs = Math.min(backoffMs * 2, MAX_RECONNECT_MS);
|
|
142
|
+
}
|
|
143
|
+
finally {
|
|
144
|
+
external?.removeEventListener("abort", onExternalAbort);
|
|
145
|
+
connection.abort();
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Send a message and drive the turn to completion: streams events, answers
|
|
151
|
+
* parked client-tool calls from `tools`, routes approval requests to
|
|
152
|
+
* `onConfirm`, and resolves when the session settles (end_turn, interrupted,
|
|
153
|
+
* or error). Throws if the session terminates mid-run or `signal` aborts.
|
|
154
|
+
*/
|
|
155
|
+
async run(text, options = {}) {
|
|
156
|
+
if (options.signal?.aborted)
|
|
157
|
+
throw new HostlerError("run aborted", 0);
|
|
158
|
+
const tools = options.tools ?? {};
|
|
159
|
+
const signal = options.signal;
|
|
160
|
+
const signalOpt = signal !== undefined ? { signal } : {};
|
|
161
|
+
// INVARIANT: once `signal` aborts, run() issues no further side-effecting
|
|
162
|
+
// request — a cancelled run must not resolve tool calls or confirmations
|
|
163
|
+
// behind the caller's back. Every helper below checks the signal before
|
|
164
|
+
// each POST and ties the POST itself to it.
|
|
165
|
+
const aborted = () => signal?.aborted === true;
|
|
166
|
+
// A call can be answered elsewhere (console operator) or cleared by an
|
|
167
|
+
// interrupt/termination between our seeing it and answering — then the
|
|
168
|
+
// answer 404s (no parked call) or 409s (not live). The stream's status
|
|
169
|
+
// events settle the run in those cases; don't fail it on the race.
|
|
170
|
+
const tolerate = async (action) => {
|
|
171
|
+
try {
|
|
172
|
+
await action;
|
|
173
|
+
}
|
|
174
|
+
catch (e) {
|
|
175
|
+
if (e instanceof HostlerError && (e.status === 404 || e.status === 409))
|
|
176
|
+
return;
|
|
177
|
+
if (aborted())
|
|
178
|
+
return; // aborted mid-POST: the final abort throw owns the outcome
|
|
179
|
+
throw e;
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
const answerCall = async (toolCallId, call) => {
|
|
183
|
+
if (aborted())
|
|
184
|
+
return;
|
|
185
|
+
const handler = tools[call.name];
|
|
186
|
+
if (handler === undefined) {
|
|
187
|
+
await tolerate(this.submitToolResult({ toolCallId, error: `no handler registered for tool "${call.name}"` }, signalOpt));
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const value = await handler(call.input, { toolCallId, name: call.name });
|
|
192
|
+
if (aborted())
|
|
193
|
+
return; // handler finished after cancellation: discard
|
|
194
|
+
await tolerate(this.submitToolResult({ toolCallId, content: stringifyResult(value) }, signalOpt));
|
|
195
|
+
}
|
|
196
|
+
catch (e) {
|
|
197
|
+
if (aborted())
|
|
198
|
+
return;
|
|
199
|
+
await tolerate(this.submitToolResult({ toolCallId, error: e instanceof Error ? e.message : String(e) }, signalOpt));
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
const decideAsk = async (call) => {
|
|
203
|
+
if (options.onConfirm === undefined)
|
|
204
|
+
return; // leave it for a console operator
|
|
205
|
+
if (aborted())
|
|
206
|
+
return;
|
|
207
|
+
const decision = await options.onConfirm(call);
|
|
208
|
+
if (aborted())
|
|
209
|
+
return;
|
|
210
|
+
const normalized = typeof decision === "string" ? { result: decision } : decision;
|
|
211
|
+
await tolerate(this.confirmTool({ toolCallId: call.toolCallId, ...normalized }, signalOpt));
|
|
212
|
+
};
|
|
213
|
+
// Cursor first; the stream replays everything after it, so nothing
|
|
214
|
+
// between the send and the first read can be missed.
|
|
215
|
+
const history = await this.events();
|
|
216
|
+
if (aborted())
|
|
217
|
+
throw new HostlerError("run aborted", 0);
|
|
218
|
+
const since = history.at(-1)?.seq ?? 0;
|
|
219
|
+
// agent.tool_use carries the input; agent.tool_pending marks the call as
|
|
220
|
+
// parked for us. Correlate the two by toolCallId.
|
|
221
|
+
const toolInputs = new Map();
|
|
222
|
+
// Seed from history: calls parked BEFORE this run started still block the
|
|
223
|
+
// session, and the stream won't replay their events past our cursor —
|
|
224
|
+
// answer them now or the run would hang on them forever.
|
|
225
|
+
const resolved = new Set();
|
|
226
|
+
const confirmed = new Set();
|
|
227
|
+
for (const event of history) {
|
|
228
|
+
if (event.type === "agent.tool_result")
|
|
229
|
+
resolved.add(event.toolCallId);
|
|
230
|
+
else if (event.type === "user.tool_confirmation")
|
|
231
|
+
confirmed.add(event.toolCallId);
|
|
232
|
+
}
|
|
233
|
+
const parkedAsks = [];
|
|
234
|
+
const parkedCalls = [];
|
|
235
|
+
for (const event of history) {
|
|
236
|
+
if (event.type === "agent.tool_use" && !resolved.has(event.toolCallId)) {
|
|
237
|
+
if (event.locale === "client") {
|
|
238
|
+
toolInputs.set(event.toolCallId, { name: event.name, input: event.input });
|
|
239
|
+
}
|
|
240
|
+
if (event.evaluatedPermission === "ask" && !confirmed.has(event.toolCallId)) {
|
|
241
|
+
parkedAsks.push({ toolCallId: event.toolCallId, name: event.name, input: event.input });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
else if (event.type === "agent.tool_pending" && event.pendingState === "async" && !resolved.has(event.toolCallId)) {
|
|
245
|
+
parkedCalls.push(event.toolCallId);
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
for (const ask of parkedAsks)
|
|
249
|
+
await decideAsk(ask);
|
|
250
|
+
for (const toolCallId of parkedCalls) {
|
|
251
|
+
const call = toolInputs.get(toolCallId);
|
|
252
|
+
if (call === undefined)
|
|
253
|
+
continue;
|
|
254
|
+
toolInputs.delete(toolCallId);
|
|
255
|
+
await answerCall(toolCallId, call);
|
|
256
|
+
}
|
|
257
|
+
// Re-check after the (async) history fetch and seeding, and tie the send
|
|
258
|
+
// itself to the signal: an abort landing anywhere in that window must not
|
|
259
|
+
// start a turn the caller no longer wants.
|
|
260
|
+
if (options.signal?.aborted)
|
|
261
|
+
throw new HostlerError("run aborted", 0);
|
|
262
|
+
const stream = this.stream({ since, ...(options.signal !== undefined ? { signal: options.signal } : {}) });
|
|
263
|
+
try {
|
|
264
|
+
await this.send(text, { ...(options.signal !== undefined ? { signal: options.signal } : {}) });
|
|
265
|
+
}
|
|
266
|
+
catch (e) {
|
|
267
|
+
if (options.signal?.aborted)
|
|
268
|
+
throw new HostlerError("run aborted", 0);
|
|
269
|
+
throw e;
|
|
270
|
+
}
|
|
271
|
+
let lastMessage = null;
|
|
272
|
+
let lastError = null;
|
|
273
|
+
for await (const event of stream) {
|
|
274
|
+
options.onEvent?.(event);
|
|
275
|
+
switch (event.type) {
|
|
276
|
+
case "agent.message":
|
|
277
|
+
lastMessage = event.text;
|
|
278
|
+
break;
|
|
279
|
+
case "agent.tool_use":
|
|
280
|
+
if (event.locale === "client") {
|
|
281
|
+
toolInputs.set(event.toolCallId, { name: event.name, input: event.input });
|
|
282
|
+
}
|
|
283
|
+
if (event.evaluatedPermission === "ask") {
|
|
284
|
+
await decideAsk({ toolCallId: event.toolCallId, name: event.name, input: event.input });
|
|
285
|
+
}
|
|
286
|
+
break;
|
|
287
|
+
case "agent.tool_pending": {
|
|
288
|
+
if (event.pendingState !== "async")
|
|
289
|
+
break; // approvals are onConfirm's job
|
|
290
|
+
const call = toolInputs.get(event.toolCallId);
|
|
291
|
+
if (call === undefined)
|
|
292
|
+
break; // not a client tool call we saw start
|
|
293
|
+
toolInputs.delete(event.toolCallId);
|
|
294
|
+
await answerCall(event.toolCallId, call);
|
|
295
|
+
break;
|
|
296
|
+
}
|
|
297
|
+
case "session.status_idle":
|
|
298
|
+
// requires_action idles are our cue to keep working the loop above;
|
|
299
|
+
// anything else ends the turn.
|
|
300
|
+
if (event.stopReason.type !== "requires_action") {
|
|
301
|
+
return { text: lastMessage, stopReason: event.stopReason };
|
|
302
|
+
}
|
|
303
|
+
break;
|
|
304
|
+
case "session.error":
|
|
305
|
+
// retryable errors may be followed by more work; non-retryable ones
|
|
306
|
+
// are followed by termination — keep the detail for that throw.
|
|
307
|
+
if (!event.retryable)
|
|
308
|
+
lastError = event.message;
|
|
309
|
+
break;
|
|
310
|
+
case "session.status_terminated":
|
|
311
|
+
throw new HostlerError(`session terminated during run: ${event.reason}${lastError !== null ? ` (${lastError})` : ""}`, 0);
|
|
312
|
+
default:
|
|
313
|
+
break;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
if (options.signal?.aborted)
|
|
317
|
+
throw new HostlerError("run aborted", 0);
|
|
318
|
+
throw new HostlerError("event stream ended before the turn completed", 0);
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
function stringifyResult(value) {
|
|
322
|
+
if (typeof value === "string")
|
|
323
|
+
return value;
|
|
324
|
+
if (value === undefined)
|
|
325
|
+
return "";
|
|
326
|
+
return JSON.stringify(value);
|
|
327
|
+
}
|
|
328
|
+
function sleep(ms, signal) {
|
|
329
|
+
// An already-aborted signal never fires its abort listeners — check first
|
|
330
|
+
// or an abort landing before the sleep waits out the full backoff.
|
|
331
|
+
if (signal?.aborted)
|
|
332
|
+
return Promise.resolve();
|
|
333
|
+
return new Promise((resolve) => {
|
|
334
|
+
const timer = setTimeout(done, ms);
|
|
335
|
+
function done() {
|
|
336
|
+
signal?.removeEventListener("abort", done);
|
|
337
|
+
clearTimeout(timer);
|
|
338
|
+
resolve();
|
|
339
|
+
}
|
|
340
|
+
signal?.addEventListener("abort", done, { once: true });
|
|
341
|
+
});
|
|
342
|
+
}
|
package/dist/sse.d.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SSE frame parser for the events endpoint. The server writes frames
|
|
3
|
+
* as `id: <seq>\ndata: <JSON>\n\n` plus comment heartbeats (`: ping`), so this
|
|
4
|
+
* implements just the subset of the SSE grammar the control plane emits —
|
|
5
|
+
* multi-line `data:` joining and comment skipping included for spec safety.
|
|
6
|
+
*/
|
|
7
|
+
export interface SseFrame {
|
|
8
|
+
id?: string;
|
|
9
|
+
data: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class SseParser {
|
|
12
|
+
#private;
|
|
13
|
+
/** Feed a decoded chunk; returns every complete frame it terminated. */
|
|
14
|
+
push(chunk: string): SseFrame[];
|
|
15
|
+
}
|
package/dist/sse.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal SSE frame parser for the events endpoint. The server writes frames
|
|
3
|
+
* as `id: <seq>\ndata: <JSON>\n\n` plus comment heartbeats (`: ping`), so this
|
|
4
|
+
* implements just the subset of the SSE grammar the control plane emits —
|
|
5
|
+
* multi-line `data:` joining and comment skipping included for spec safety.
|
|
6
|
+
*/
|
|
7
|
+
export class SseParser {
|
|
8
|
+
#buffer = "";
|
|
9
|
+
/** Feed a decoded chunk; returns every complete frame it terminated. */
|
|
10
|
+
push(chunk) {
|
|
11
|
+
this.#buffer += chunk;
|
|
12
|
+
const frames = [];
|
|
13
|
+
let idx;
|
|
14
|
+
while ((idx = this.#buffer.indexOf("\n\n")) !== -1) {
|
|
15
|
+
const raw = this.#buffer.slice(0, idx);
|
|
16
|
+
this.#buffer = this.#buffer.slice(idx + 2);
|
|
17
|
+
const frame = parseFrame(raw);
|
|
18
|
+
if (frame !== null)
|
|
19
|
+
frames.push(frame);
|
|
20
|
+
}
|
|
21
|
+
return frames;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function parseFrame(raw) {
|
|
25
|
+
let id;
|
|
26
|
+
const data = [];
|
|
27
|
+
for (const line of raw.split("\n")) {
|
|
28
|
+
if (line.startsWith(":"))
|
|
29
|
+
continue; // comment (heartbeat)
|
|
30
|
+
const colon = line.indexOf(":");
|
|
31
|
+
if (colon === -1)
|
|
32
|
+
continue;
|
|
33
|
+
const field = line.slice(0, colon);
|
|
34
|
+
// Per the SSE spec a single space after the colon is not part of the value.
|
|
35
|
+
let value = line.slice(colon + 1);
|
|
36
|
+
if (value.startsWith(" "))
|
|
37
|
+
value = value.slice(1);
|
|
38
|
+
if (field === "id")
|
|
39
|
+
id = value;
|
|
40
|
+
else if (field === "data")
|
|
41
|
+
data.push(value);
|
|
42
|
+
}
|
|
43
|
+
if (data.length === 0)
|
|
44
|
+
return null; // comment-only frame (heartbeat)
|
|
45
|
+
return { ...(id !== undefined ? { id } : {}), data: data.join("\n") };
|
|
46
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** HTTP plumbing shared by the resource classes and the session handle. */
|
|
2
|
+
export declare class HostlerError extends Error {
|
|
3
|
+
/** HTTP status of the failing response, or 0 when the failure wasn't an HTTP error. */
|
|
4
|
+
readonly status: number;
|
|
5
|
+
constructor(message: string, status: number);
|
|
6
|
+
}
|
|
7
|
+
export interface TransportOptions {
|
|
8
|
+
apiKey: string;
|
|
9
|
+
baseUrl: string;
|
|
10
|
+
fetch: typeof fetch;
|
|
11
|
+
}
|
|
12
|
+
interface RawRequestInit {
|
|
13
|
+
accept?: string;
|
|
14
|
+
signal?: AbortSignal;
|
|
15
|
+
}
|
|
16
|
+
export declare class Transport {
|
|
17
|
+
#private;
|
|
18
|
+
constructor(options: TransportOptions);
|
|
19
|
+
/**
|
|
20
|
+
* JSON request/response. Non-2xx responses throw HostlerError with the
|
|
21
|
+
* server's `{ "error": "..." }` message.
|
|
22
|
+
*/
|
|
23
|
+
request<T>(method: string, path: string, body?: unknown, init?: {
|
|
24
|
+
signal?: AbortSignal;
|
|
25
|
+
}): Promise<T>;
|
|
26
|
+
/** Raw response for streaming (SSE) and binary (output files) reads. */
|
|
27
|
+
requestRaw(path: string, init?: RawRequestInit): Promise<Response>;
|
|
28
|
+
}
|
|
29
|
+
export {};
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** HTTP plumbing shared by the resource classes and the session handle. */
|
|
2
|
+
export class HostlerError extends Error {
|
|
3
|
+
/** HTTP status of the failing response, or 0 when the failure wasn't an HTTP error. */
|
|
4
|
+
status;
|
|
5
|
+
constructor(message, status) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = "HostlerError";
|
|
8
|
+
this.status = status;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
function isRecord(v) {
|
|
12
|
+
return typeof v === "object" && v !== null;
|
|
13
|
+
}
|
|
14
|
+
export class Transport {
|
|
15
|
+
#apiKey;
|
|
16
|
+
#baseUrl;
|
|
17
|
+
#fetch;
|
|
18
|
+
constructor(options) {
|
|
19
|
+
this.#apiKey = options.apiKey;
|
|
20
|
+
this.#baseUrl = options.baseUrl.replace(/\/+$/, "");
|
|
21
|
+
this.#fetch = options.fetch;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* JSON request/response. Non-2xx responses throw HostlerError with the
|
|
25
|
+
* server's `{ "error": "..." }` message.
|
|
26
|
+
*/
|
|
27
|
+
async request(method, path, body, init = {}) {
|
|
28
|
+
const res = await this.#fetch(`${this.#baseUrl}${path}`, {
|
|
29
|
+
method,
|
|
30
|
+
headers: {
|
|
31
|
+
authorization: `Bearer ${this.#apiKey}`,
|
|
32
|
+
...(body !== undefined ? { "content-type": "application/json" } : {}),
|
|
33
|
+
},
|
|
34
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
35
|
+
...(init.signal !== undefined ? { signal: init.signal } : {}),
|
|
36
|
+
});
|
|
37
|
+
const text = await res.text();
|
|
38
|
+
if (!res.ok)
|
|
39
|
+
throw new HostlerError(errorMessage(text, res.status), res.status);
|
|
40
|
+
const payload = text === "" ? undefined : JSON.parse(text);
|
|
41
|
+
// The one deliberate widening in the SDK: 2xx bodies are trusted to match
|
|
42
|
+
// the declared wire types — they are pinned against the real control-plane
|
|
43
|
+
// server by the cross-package integration test — rather than re-validated
|
|
44
|
+
// on every call.
|
|
45
|
+
return payload;
|
|
46
|
+
}
|
|
47
|
+
/** Raw response for streaming (SSE) and binary (output files) reads. */
|
|
48
|
+
async requestRaw(path, init = {}) {
|
|
49
|
+
const res = await this.#fetch(`${this.#baseUrl}${path}`, {
|
|
50
|
+
method: "GET",
|
|
51
|
+
headers: {
|
|
52
|
+
authorization: `Bearer ${this.#apiKey}`,
|
|
53
|
+
...(init.accept !== undefined ? { accept: init.accept } : {}),
|
|
54
|
+
},
|
|
55
|
+
...(init.signal !== undefined ? { signal: init.signal } : {}),
|
|
56
|
+
});
|
|
57
|
+
if (!res.ok) {
|
|
58
|
+
const text = await res.text();
|
|
59
|
+
throw new HostlerError(errorMessage(text, res.status), res.status);
|
|
60
|
+
}
|
|
61
|
+
return res;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
function errorMessage(bodyText, status) {
|
|
65
|
+
try {
|
|
66
|
+
const parsed = JSON.parse(bodyText);
|
|
67
|
+
if (isRecord(parsed) && typeof parsed["error"] === "string")
|
|
68
|
+
return parsed["error"];
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
// non-JSON error body — fall through to the generic message
|
|
72
|
+
}
|
|
73
|
+
return `HTTP ${status}: ${bodyText.slice(0, 200)}`;
|
|
74
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types for the Hostler /v1 API. These mirror the server's contract
|
|
3
|
+
* (control-plane/src/http.ts request schemas, control-plane/src/db.ts row
|
|
4
|
+
* shapes, and the runtime's SessionEvent union in runtime/src/types.ts) —
|
|
5
|
+
* the SDK is published standalone, so the shapes are declared here rather
|
|
6
|
+
* than imported, and the cross-repo integration test pins them against the
|
|
7
|
+
* real server.
|
|
8
|
+
*/
|
|
9
|
+
export interface ModelConfig {
|
|
10
|
+
provider: string;
|
|
11
|
+
id: string;
|
|
12
|
+
/** Override the provider's default API endpoint. */
|
|
13
|
+
upstreamBaseUrl?: string;
|
|
14
|
+
[extra: string]: unknown;
|
|
15
|
+
}
|
|
16
|
+
export interface ClientToolDefinition {
|
|
17
|
+
name: string;
|
|
18
|
+
description: string;
|
|
19
|
+
/** JSON Schema for the tool's input. */
|
|
20
|
+
inputSchema: Record<string, unknown>;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* An agent's versioned configuration. `name`, `harness`, and `model` are
|
|
24
|
+
* validated by the server; further fields (toolConfigs, mcpServers, skills,
|
|
25
|
+
* ...) pass through to the runtime, which owns those contracts.
|
|
26
|
+
*/
|
|
27
|
+
export interface AgentConfig {
|
|
28
|
+
name: string;
|
|
29
|
+
/** Which agent harness runs in the sandbox (e.g. "pi"). */
|
|
30
|
+
harness: string;
|
|
31
|
+
model: ModelConfig;
|
|
32
|
+
/** System prompt. */
|
|
33
|
+
system?: string;
|
|
34
|
+
/**
|
|
35
|
+
* Which of the harness's own built-in tools run in the sandbox. `[]`
|
|
36
|
+
* disables all of them; omit for the harness default.
|
|
37
|
+
*/
|
|
38
|
+
sandboxTools?: string[];
|
|
39
|
+
/** Tools executed by YOUR application via the tool_results loop. */
|
|
40
|
+
clientTools?: ClientToolDefinition[];
|
|
41
|
+
[extra: string]: unknown;
|
|
42
|
+
}
|
|
43
|
+
/** One immutable version of an agent, as stored by the control plane. */
|
|
44
|
+
export interface Agent {
|
|
45
|
+
id: string;
|
|
46
|
+
version: number;
|
|
47
|
+
config: AgentConfig;
|
|
48
|
+
createdAt: string;
|
|
49
|
+
}
|
|
50
|
+
export type SessionStatus = "starting" | "running" | "idle" | "terminated";
|
|
51
|
+
export interface SessionInfo {
|
|
52
|
+
id: string;
|
|
53
|
+
agentId: string;
|
|
54
|
+
agentVersion: number;
|
|
55
|
+
status: SessionStatus;
|
|
56
|
+
title: string | null;
|
|
57
|
+
createdAt: string;
|
|
58
|
+
terminatedAt: string | null;
|
|
59
|
+
environmentId: string | null;
|
|
60
|
+
vaultIds: string[];
|
|
61
|
+
deploymentId: string | null;
|
|
62
|
+
}
|
|
63
|
+
export interface SessionResourceMount {
|
|
64
|
+
type: "memory_store";
|
|
65
|
+
memoryStoreId: string;
|
|
66
|
+
access?: "read_write" | "read_only";
|
|
67
|
+
instructions?: string;
|
|
68
|
+
}
|
|
69
|
+
export interface CreateSessionOptions {
|
|
70
|
+
agentId: string;
|
|
71
|
+
/** Pin a specific agent version; omit for the latest. */
|
|
72
|
+
agentVersion?: number;
|
|
73
|
+
title?: string;
|
|
74
|
+
environmentId?: string;
|
|
75
|
+
vaultIds?: string[];
|
|
76
|
+
resources?: SessionResourceMount[];
|
|
77
|
+
}
|
|
78
|
+
export interface OutputFile {
|
|
79
|
+
/** Path relative to the session's outputs directory. */
|
|
80
|
+
path: string;
|
|
81
|
+
sizeBytes: number;
|
|
82
|
+
}
|
|
83
|
+
export type StopReason = {
|
|
84
|
+
type: "end_turn";
|
|
85
|
+
} | {
|
|
86
|
+
type: "requires_action";
|
|
87
|
+
toolCallIds: string[];
|
|
88
|
+
} | {
|
|
89
|
+
type: "interrupted";
|
|
90
|
+
} | {
|
|
91
|
+
type: "error";
|
|
92
|
+
message: string;
|
|
93
|
+
};
|
|
94
|
+
export type ToolResultContent = {
|
|
95
|
+
type: "text";
|
|
96
|
+
text: string;
|
|
97
|
+
};
|
|
98
|
+
interface EventBase {
|
|
99
|
+
/** Monotonic per-session sequence — the cursor for `since` / Last-Event-ID. */
|
|
100
|
+
seq: number;
|
|
101
|
+
/** Unique event id, `sevt_<...>`. */
|
|
102
|
+
id: string;
|
|
103
|
+
/** ms epoch when the event was appended. */
|
|
104
|
+
ts: number;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* The append-only session event log. New event types may be added by the
|
|
108
|
+
* server over time — consumers should ignore types they don't recognize
|
|
109
|
+
* rather than treating the union as closed.
|
|
110
|
+
*/
|
|
111
|
+
export type SessionEvent = EventBase & ({
|
|
112
|
+
type: "session.status_running";
|
|
113
|
+
} | {
|
|
114
|
+
type: "session.status_idle";
|
|
115
|
+
stopReason: StopReason;
|
|
116
|
+
} | {
|
|
117
|
+
type: "session.status_terminated";
|
|
118
|
+
reason: string;
|
|
119
|
+
} | {
|
|
120
|
+
type: "session.error";
|
|
121
|
+
message: string;
|
|
122
|
+
retryable: boolean;
|
|
123
|
+
} | {
|
|
124
|
+
type: "user.message";
|
|
125
|
+
text: string;
|
|
126
|
+
} | {
|
|
127
|
+
type: "user.interrupt";
|
|
128
|
+
} | {
|
|
129
|
+
type: "agent.message_delta";
|
|
130
|
+
text: string;
|
|
131
|
+
} | {
|
|
132
|
+
type: "agent.message";
|
|
133
|
+
text: string;
|
|
134
|
+
} | {
|
|
135
|
+
type: "agent.thinking_delta";
|
|
136
|
+
text: string;
|
|
137
|
+
} | {
|
|
138
|
+
type: "agent.tool_use";
|
|
139
|
+
toolCallId: string;
|
|
140
|
+
name: string;
|
|
141
|
+
input: unknown;
|
|
142
|
+
/** "client" = your application executes it; "sandbox" = harness-local; "mcp" = runtime-side MCP dispatch. */
|
|
143
|
+
locale: "client" | "sandbox" | "mcp";
|
|
144
|
+
/** Present for client/mcp tools: "ask" means the call is parked awaiting a tool confirmation. */
|
|
145
|
+
evaluatedPermission?: "allow" | "ask";
|
|
146
|
+
} | {
|
|
147
|
+
type: "user.tool_confirmation";
|
|
148
|
+
toolCallId: string;
|
|
149
|
+
result: "allow" | "deny";
|
|
150
|
+
denyMessage?: string;
|
|
151
|
+
} | {
|
|
152
|
+
type: "span.model_request_start";
|
|
153
|
+
requestId: string;
|
|
154
|
+
} | {
|
|
155
|
+
type: "span.model_request_end";
|
|
156
|
+
requestId: string;
|
|
157
|
+
httpStatus: number;
|
|
158
|
+
usage?: {
|
|
159
|
+
input: number;
|
|
160
|
+
output: number;
|
|
161
|
+
cacheRead: number;
|
|
162
|
+
cacheWrite: number;
|
|
163
|
+
};
|
|
164
|
+
} | {
|
|
165
|
+
type: "span.outcome_evaluation_start";
|
|
166
|
+
outcomeId: string;
|
|
167
|
+
iteration: number;
|
|
168
|
+
} | {
|
|
169
|
+
type: "span.outcome_evaluation_end";
|
|
170
|
+
outcomeId: string;
|
|
171
|
+
iteration: number;
|
|
172
|
+
result: "satisfied" | "needs_revision" | "max_iterations_reached" | "failed" | "interrupted";
|
|
173
|
+
explanation: string;
|
|
174
|
+
} | {
|
|
175
|
+
type: "session.updated";
|
|
176
|
+
changed: string[];
|
|
177
|
+
} | {
|
|
178
|
+
type: "agent.tool_pending";
|
|
179
|
+
toolCallId: string;
|
|
180
|
+
name: string;
|
|
181
|
+
pendingState: "approval" | "async";
|
|
182
|
+
description?: string;
|
|
183
|
+
} | {
|
|
184
|
+
type: "agent.tool_result";
|
|
185
|
+
toolCallId: string;
|
|
186
|
+
name: string;
|
|
187
|
+
isError: boolean;
|
|
188
|
+
content: ToolResultContent[];
|
|
189
|
+
} | {
|
|
190
|
+
type: "span.model_usage";
|
|
191
|
+
usage: {
|
|
192
|
+
input: number;
|
|
193
|
+
output: number;
|
|
194
|
+
cacheRead: number;
|
|
195
|
+
cacheWrite: number;
|
|
196
|
+
costUsd?: number;
|
|
197
|
+
};
|
|
198
|
+
});
|
|
199
|
+
/** Exactly one of `content` or `error` — the server rejects both/neither. */
|
|
200
|
+
export type ToolResult = {
|
|
201
|
+
toolCallId: string;
|
|
202
|
+
content: string;
|
|
203
|
+
error?: undefined;
|
|
204
|
+
} | {
|
|
205
|
+
toolCallId: string;
|
|
206
|
+
error: string;
|
|
207
|
+
content?: undefined;
|
|
208
|
+
};
|
|
209
|
+
export interface ToolConfirmation {
|
|
210
|
+
toolCallId: string;
|
|
211
|
+
result: "allow" | "deny";
|
|
212
|
+
/** Shown to the model verbatim on deny (defaults to "denied by user"). */
|
|
213
|
+
denyMessage?: string;
|
|
214
|
+
}
|
|
215
|
+
export {};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Wire types for the Hostler /v1 API. These mirror the server's contract
|
|
3
|
+
* (control-plane/src/http.ts request schemas, control-plane/src/db.ts row
|
|
4
|
+
* shapes, and the runtime's SessionEvent union in runtime/src/types.ts) —
|
|
5
|
+
* the SDK is published standalone, so the shapes are declared here rather
|
|
6
|
+
* than imported, and the cross-repo integration test pins them against the
|
|
7
|
+
* real server.
|
|
8
|
+
*/
|
|
9
|
+
export {};
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@hostler/sdk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "TypeScript SDK for the Hostler API: managed agents, sessions, live event streams, and client-tool execution.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"homepage": "https://hostler.dev/docs/sdk",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"files": [
|
|
15
|
+
"dist",
|
|
16
|
+
"README.md"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=18"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p tsconfig.build.json",
|
|
23
|
+
"prepack": "npm run build",
|
|
24
|
+
"typecheck": "tsc --noEmit",
|
|
25
|
+
"test": "node --test --test-timeout=60000 'test/*.test.ts'"
|
|
26
|
+
},
|
|
27
|
+
"devDependencies": {
|
|
28
|
+
"@types/node": "^24.0.0",
|
|
29
|
+
"typescript": "^5.8.0"
|
|
30
|
+
}
|
|
31
|
+
}
|