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
Binary file modified docs/images/hostler/settings-card.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions package-lock.json

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@
"@anthropic-ai/sdk": "^0.98.0",
"@electron-toolkit/utils": "^4.0.0",
"@floating-ui/dom": "^1.7.6",
"@hostler/sdk": "^0.1.0",
"@hostler/sdk": "^0.2.10",
"@modelcontextprotocol/sdk": "^1.26.0",
"@opencode-ai/sdk": "^1.15.10",
"@tanstack/react-query": "^5.62.0",
Expand Down
60 changes: 49 additions & 11 deletions src/main/agents/providers/hostler/hostler-agent-provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import type {
AgentConfig,
CreateSessionOptions,
ModelConfig,
RecoverySafety,
SessionEvent,
SessionInfo,
StreamOptions,
ToolResult,
} from "@hostler/sdk";
import { randomUUID } from "node:crypto";
import type {
AgentContext,
AgentEvent,
Expand All @@ -34,16 +36,13 @@ import { DEFAULT_HOSTLER_HARNESS } from "../../../../shared/types";

const log = createLogger("hostler-agent");

/** Default pairing: the opencode harness driving GLM 5.2 through Hostler's
* own model broker (GET /v1/models catalog id "glm-5.2", provider "openai" —
* open-weights models ride the broker's openai wire shape). GLM 5.2 is the
* same model the app's Ollama Cloud integration defaults to
* (DEFAULT_OLLAMA_MODEL), chosen there after a 16-task agent benchmark.
* Model ids are validated against the catalog at session create, so a
* mistyped id fails fast rather than after the sandbox starts billing. */
/** Default pairing: the opencode harness driving Kimi K3 through Hostler's
* model broker. Kimi uses the broker's OpenAI-compatible wire shape.
* Model ids are validated by Hostler at session create, so a mistyped or
* unavailable id fails fast rather than after the sandbox starts billing. */
export const DEFAULT_HOSTLER_MODEL: ModelConfig = {
provider: "openai",
id: "glm-5.2",
id: "kimi-k3",
};

/**
Expand Down Expand Up @@ -73,6 +72,7 @@ export function resolveHostlerModel(selector: string | undefined): ModelConfig {
export interface HostlerSessionLike {
readonly id: string;
info(): Promise<SessionInfo>;
recoverySafety(options?: { afterSeq?: number; timeoutMs?: number }): Promise<RecoverySafety>;
send(text: string, options?: { signal?: AbortSignal }): Promise<void>;
interrupt(): Promise<void>;
terminate(): Promise<SessionInfo>;
Expand Down Expand Up @@ -429,7 +429,17 @@ export class HostlerAgentProvider implements AgentProvider {
};
break;
} else if (ev.type === "session.status_terminated") {
yield { type: "error", message: `Hostler session terminated: ${ev.reason}` };
const recovery = await session
.recoverySafety({ afterSeq: ev.seq })
.catch((): null => null);
const failureCode = ev.failure?.code ? ` [${ev.failure.code}]` : "";
const recoveryMessage = recovery
? ` Recovery safety: ${recovery.classification.replaceAll("_", " ")} — ${recovery.explanation.slice(0, 1_000)}`
: "";
yield {
type: "error",
message: `Hostler session terminated${failureCode}: ${ev.reason}.${recoveryMessage}`,
};
this.sessionSeq.delete(session.id);
cleanup();
return { state: "failed", providerTaskId: session.id };
Expand Down Expand Up @@ -542,15 +552,33 @@ export class HostlerAgentProvider implements AgentProvider {
agentRef: { id: string; version: number },
taskId: string,
): Promise<HostlerSessionLike> {
// SDK 0.2 lets the caller allocate a team-scoped alias. If session
// creation succeeds server-side but its HTTP response is lost, we can
// recover the exact sandbox through that alias instead of leaking it and
// launching a duplicate. The returned session.id may be a distinct,
// server-generated internal id; both identities work on session routes.
const sessionId = `ses_${randomUUID().replaceAll("-", "")}`;
const options: CreateSessionOptions = {
sessionId,
agentId: agentRef.id,
// Pin the version we just synced — deterministic even if another
// device publishes a newer version mid-run.
agentVersion: agentRef.version,
title: `mail-app:${taskId}`,
};
const createOrRecover = async (): Promise<HostlerSessionLike> => {
try {
return await client.sessions.create(options);
} catch (err) {
if (!isAmbiguousCreateError(err)) throw err;
const recovered = await client.sessions.get(sessionId).catch(() => null);
if (!recovered) throw err;
log.info(`Recovered Hostler session ${sessionId} after an ambiguous create response`);
return recovered;
}
};
try {
return await client.sessions.create(options);
return await createOrRecover();
} catch (err) {
if (errorStatus(err) !== 402) throw err;
// Reservations may be held by our own warm sessions OR by orphans the
Expand All @@ -559,7 +587,7 @@ export class HostlerAgentProvider implements AgentProvider {
log.info("Session create hit a credit reservation (402); freeing our sessions and retrying");
await this.orphanSweep?.catch(() => undefined);
await this.terminateWarmSessions();
return await client.sessions.create(options);
return await createOrRecover();
}
}

Expand Down Expand Up @@ -608,6 +636,7 @@ export class HostlerAgentProvider implements AgentProvider {
const staleBefore = Date.now() - STALE_RUNNING_SESSION_MS;
const orphans = rows.filter((row) => {
if (!(row.title ?? "").startsWith("mail-app:")) return false;
if (row.status === "terminated") return false;
if (row.status === "idle") return true;
const created = Date.parse(row.createdAt);
return Number.isFinite(created) && created < staleBefore;
Expand Down Expand Up @@ -822,6 +851,15 @@ function isConflict(err: unknown): boolean {
return status === 409 || status === 404;
}

/** A timeout, connection loss, conflict, or server failure can happen after
* Hostler persisted the caller-assigned session id but before the create
* response reached us. Definitive client/auth/billing errors are not
* ambiguous and should surface without a follow-up lookup. */
function isAmbiguousCreateError(err: unknown): boolean {
const status = errorStatus(err);
return status === null || status === 408 || status === 409 || status >= 500;
}

function describeHostlerError(err: unknown, doing: string): string {
const message = err instanceof Error ? err.message : String(err);
switch (errorStatus(err)) {
Expand Down
4 changes: 2 additions & 2 deletions src/renderer/components/ExtensionsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1023,7 +1023,7 @@ export function ExtensionsTab({ onOllamaCloudDisabled }: { onOllamaCloudDisabled
<input
type="text"
className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-400"
placeholder="openai/glm-5.2"
placeholder="openai/kimi-k3"
value={hostlerModel}
disabled={hostlerSaveState === "saving"}
onChange={(e) => {
Expand All @@ -1034,7 +1034,7 @@ export function ExtensionsTab({ onOllamaCloudDisabled }: { onOllamaCloudDisabled
/>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
Bare model id (pairs with Anthropic) or &quot;provider/model&quot; from
Hostler&apos;s catalog. Blank uses glm-5.2.
Hostler&apos;s catalog. Blank uses Kimi K3.
</p>
</div>
</div>
Expand Down
7 changes: 3 additions & 4 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -508,10 +508,9 @@ export const ConfigSchema = z.object({
// work without an app update — unknown ones fail fast with a 400 that
// lists the supported set.
harness: z.string().default(DEFAULT_HOSTLER_HARNESS),
// "provider/model" (e.g. "openai/kimi-k2.5") or a bare model id, which
// pairs with "anthropic". Blank uses glm-5.2 from Hostler's brokered
// catalog (GET /v1/models) — the same model family as the app's own
// Ollama Cloud default.
// "provider/model" (e.g. "openai/kimi-k3") or a bare model id, which
// pairs with "anthropic". Blank uses Kimi K3 through Hostler's
// OpenAI-compatible broker route.
model: z.string().optional(),
// Dev/test escape hatch (e.g. scripts/mock-hostler-server.mjs); no UI,
// and the settings IPC only accepts loopback values (see settings.ipc).
Expand Down
11 changes: 8 additions & 3 deletions tests/e2e/hostler-settings.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,13 @@ test.describe("Settings - Hostler", () => {
const apiKeyInput = card.getByPlaceholder("cpk_...");
await apiKeyInput.fill("");
await apiKeyInput.fill("cpk_e2e_test");
if (process.env.E2E_SCREENSHOTS === "true") {
await card.getByPlaceholder("opencode").fill("");
await card.getByPlaceholder("openai/kimi-k3").fill("");
await card.screenshot({ path: "docs/images/hostler/settings-card.png" });
}
await card.getByPlaceholder("opencode").fill("codex");
await card.getByPlaceholder("openai/glm-5.2").fill("openai/test-model");
await card.getByPlaceholder("openai/kimi-k3").fill("openai/test-model");

await card.getByRole("button", { name: "Save", exact: true }).click();
await expect(card.getByRole("button", { name: "Saved", exact: true })).toBeVisible();
Expand Down Expand Up @@ -93,7 +98,7 @@ test.describe("Settings - Hostler", () => {

await expect(card.getByPlaceholder("cpk_...")).toHaveValue("cpk_e2e_test");
await expect(card.getByPlaceholder("opencode")).toHaveValue("codex");
await expect(card.getByPlaceholder("openai/glm-5.2")).toHaveValue("openai/test-model");
await expect(card.getByPlaceholder("openai/kimi-k3")).toHaveValue("openai/test-model");
} finally {
const restored = (await page.evaluate(
(hostler) => window.api.settings.set({ hostler }),
Expand Down Expand Up @@ -149,7 +154,7 @@ test.describe("Settings - Hostler", () => {
await expect(toggle).toBeDisabled();
await expect(card.getByPlaceholder("cpk_...")).toBeDisabled();
await expect(card.getByPlaceholder("opencode")).toBeDisabled();
await expect(card.getByPlaceholder("openai/glm-5.2")).toBeDisabled();
await expect(card.getByPlaceholder("openai/kimi-k3")).toBeDisabled();
await expect
.poll(() =>
electronApp.evaluate(() => {
Expand Down
Loading
Loading