Loom / app /src /api /agent.ts
deploy-space action
deploy 6158a00 (c)
b972454
Raw
History Blame Contribute Delete
5.69 kB
// Typed client for the agent orchestrator (the "agent" tab). Mirrors the
// backend's polling contract: POST /api/agent starts/continues a session,
// GET /api/agent/<sid>?since=<n> returns an append-only transcript delta.
// Reuses the same windowed-bearer derivation + 401-retry as PanelClient.
import { deriveToken } from "./token";
import type { Settings } from "./panel";
import { ApiError } from "./panel";
export type AgentEvent =
| { i: number; ts: number; type: "user"; text: string }
| { i: number; ts: number; type: "assistant"; text: string }
| { i: number; ts: number; type: "thinking"; text: string }
| { i: number; ts: number; type: "tool_use"; name: string; summary: string }
| { i: number; ts: number; type: "tool_result"; text: string; is_error: boolean }
| {
i: number;
ts: number;
type: "status";
state: "starting" | "idle" | "running" | "error";
detail?: string;
cost_usd?: number | null;
};
export type AgentStatus = "starting" | "idle" | "running" | "error";
export interface AgentModel {
tier: "claude" | "open";
provider: string;
model: string;
label: string;
default: boolean;
}
export interface AgentSnapshot {
session_id: string;
tier: "claude" | "open" | "mock";
model: string | null;
status: AgentStatus;
events: AgentEvent[];
next: number;
}
export interface AgentStart {
session_id: string;
tier: "claude" | "open" | "mock";
model: string | null;
status: AgentStatus;
workspace_id?: string;
}
export interface AgentFile {
path: string;
size: number;
mtime: number;
}
export class AgentClient {
constructor(private settings: Settings) {}
private async bearer(windowsBack = 0): Promise<string> {
if (this.settings.rotationSecret) return deriveToken(this.settings.rotationSecret, windowsBack);
return this.settings.token;
}
private fetchWith(token: string, path: string, init?: RequestInit): Promise<Response> {
return fetch(this.settings.baseUrl + path, {
...init,
headers: {
...(init?.body ? { "Content-Type": "application/json" } : {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
...(init?.headers || {}),
},
});
}
/** Authenticated fetch with a single 401 retry on the previous token window. */
private async raw(path: string, init?: RequestInit): Promise<Response> {
let r = await this.fetchWith(await this.bearer(), path, init);
if (r.status === 401 && this.settings.rotationSecret) {
r = await this.fetchWith(await this.bearer(1), path, init);
}
return r;
}
private async req<T>(path: string, init?: RequestInit): Promise<T> {
const r = await this.raw(path, init);
if (!r.ok) {
let msg = `HTTP ${r.status}`;
try {
const j = await r.json();
if (j?.error) msg = j.error;
} catch {
/* non-json body */
}
throw new ApiError(r.status, msg);
}
return (await r.json()) as T;
}
/** List models this Space can actually run (for the picker). */
models() {
return this.req<{ tier: string | null; models: AgentModel[] }>("/api/agent/models");
}
/** Start a new session, or continue an existing one if sessionId is given.
* `model` selects which model/tier drives a NEW session.
* `workspaceId` links the agent to a user workspace sandbox. */
send(message: string, sessionId?: string, model?: string, workspaceId?: string) {
const body: Record<string, unknown> = { message };
if (sessionId) body.session_id = sessionId;
if (model) body.model = model;
if (workspaceId) body.workspace_id = workspaceId;
// When linking to a workspace, the backend additionally needs the caller's
// GitHub identity (JWT) for the ownership check. Send it in X-JWT so the
// Authorization header stays reserved for the service/rotation token.
const headers: Record<string, string> = {};
if (workspaceId && this.settings.githubSessionId) {
headers["X-JWT"] = this.settings.githubSessionId;
}
return this.req<AgentStart>("/api/agent", {
method: "POST",
body: JSON.stringify(body),
headers,
});
}
/** Stop the in-flight turn for a session. */
interrupt(sessionId: string) {
return this.req<{ interrupted: boolean; status: AgentStatus }>(
`/api/agent/${sessionId}/interrupt`,
{ method: "POST" },
);
}
/** Poll the transcript; `since` is the cursor returned as `next` last time.
* NOTE: Session ID is in the URL path (not a header) because GET requests can't
* have bodies. Backend logs should redact session IDs to prevent leakage via
* proxy/CDN access logs. A future improvement could use short-lived signed tokens. */
poll(sessionId: string, since: number) {
return this.req<AgentSnapshot>(`/api/agent/${sessionId}?since=${since}`);
}
files(sessionId: string) {
return this.req<{ session_id: string; files: AgentFile[] }>(`/api/agent/${sessionId}/files`);
}
/** Download an artifact. Plain <a href> can't carry the bearer, so we fetch
* the bytes ourselves and trigger a download from an object URL. */
async download(sessionId: string, path: string): Promise<void> {
const r = await this.raw(`/api/agent/${sessionId}/file?path=${encodeURIComponent(path)}`);
if (!r.ok) throw new ApiError(r.status, `download failed (HTTP ${r.status})`);
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = path.split("/").pop() || "download";
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
}
}