@coltonkirsten/a12s 0.3.0 → 0.4.1

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.
@@ -16,6 +16,16 @@ export class A12sError extends Error {
16
16
  this.details = body.details;
17
17
  }
18
18
  }
19
+ /** A 404: the agent computer, Service, Message or token does not exist. */
20
+ export class NotFound extends A12sError {
21
+ constructor(status, body) {
22
+ super(status, body);
23
+ this.name = "NotFound";
24
+ }
25
+ }
26
+ function apiError(status, body) {
27
+ return status === 404 ? new NotFound(status, body) : new A12sError(status, body);
28
+ }
19
29
  /** Thrown by an event stream with `onGap: "throw"` when events were lost. */
20
30
  export class A12sGapError extends Error {
21
31
  after;
@@ -35,10 +45,15 @@ export class A12sStreamError extends Error {
35
45
  }
36
46
  }
37
47
  const enc = encodeURIComponent;
48
+ /**
49
+ * @deprecated Use {@link Fleet}: `fleet.agentComputers.get(name)` returns an {@link AgentComputer} with these
50
+ * methods. Kept for one release.
51
+ */
38
52
  export class A12sClient {
39
53
  url;
40
54
  token;
41
55
  fetchImpl;
56
+ shared;
42
57
  constructor(opts = {}) {
43
58
  this.url = (opts.url ?? "").replace(/\/+$/, "");
44
59
  this.token = opts.token;
@@ -47,16 +62,17 @@ export class A12sClient {
47
62
  headers(extra = {}) {
48
63
  return this.token ? { authorization: `Bearer ${this.token}`, ...extra } : extra;
49
64
  }
50
- async request(method, path, body, signal) {
65
+ /** @internal */
66
+ async request(method, path, body, opts = {}) {
51
67
  const res = await this.fetchImpl(this.url + path, {
52
68
  method,
53
- headers: this.headers(body === undefined ? {} : { "content-type": "application/json" }),
69
+ headers: this.headers({ ...(body === undefined ? {} : { "content-type": "application/json" }), ...opts.headers }),
54
70
  body: body === undefined ? undefined : JSON.stringify(body),
55
- signal,
71
+ signal: opts.signal,
56
72
  });
57
73
  const text = await res.text();
58
74
  if (!res.ok)
59
- throw new A12sError(res.status, parseError(res.status, text));
75
+ throw apiError(res.status, parseError(res.status, text));
60
76
  return (text ? JSON.parse(text) : undefined);
61
77
  }
62
78
  /** Every Computer and Service: phase, session, active Turn, inbox, power. */
@@ -64,9 +80,9 @@ export class A12sClient {
64
80
  return this.request("GET", "/v1/status");
65
81
  }
66
82
  async computer(name) {
67
- const c = (await this.status()).computers.find((x) => x.name === name);
83
+ const c = (await this.status()).agentComputers.find((x) => x.name === name);
68
84
  if (!c)
69
- throw new A12sError(404, { code: "not_found", message: `no Computer ${name}` });
85
+ throw new NotFound(404, { code: "not_found", message: `no Computer ${name}` });
70
86
  return c;
71
87
  }
72
88
  /**
@@ -76,7 +92,7 @@ export class A12sClient {
76
92
  send(computer, msg) {
77
93
  const m = typeof msg === "string" ? { body: msg } : { ...msg };
78
94
  m.idempotencyKey ??= globalThis.crypto.randomUUID();
79
- return this.request("POST", `/v1/computers/${enc(computer)}/inbox`, m);
95
+ return this.request("POST", `/v1/agent-computers/${enc(computer)}/inbox`, m);
80
96
  }
81
97
  /** Sends a Message, waits for the Turn that delivers it and resolves when that Turn ends: what `a12s prompt` does. */
82
98
  async prompt(computer, body, opts = {}) {
@@ -105,8 +121,21 @@ export class A12sClient {
105
121
  }
106
122
  return new EventStream((name, live, signal) => {
107
123
  const q = opts.turn ? `?turn=${enc(opts.turn)}` : "";
108
- return this.stream(name, `/v1/computers/${enc(name)}/events${q}`, opts.after, { ...opts, signal, live });
109
- }, names ?? (async () => (await this.status()).computers.map((c) => c.name)), opts.signal);
124
+ return this.stream(name, `/v1/agent-computers/${enc(name)}/events${q}`, opts.after, { ...opts, signal, live });
125
+ }, names ?? (async () => (await this.status()).agentComputers.map((c) => c.name)), opts.signal);
126
+ }
127
+ /**
128
+ * @internal Events of the named agent computers (all if none), and the Controller's own about them, read from this
129
+ * client's one GET /v1/events connection.
130
+ */
131
+ sharedEvents(names, opts = {}) {
132
+ this.shared ??= new SharedEvents((cursor, signal) => this.fetchImpl(this.url + "/v1/events", {
133
+ headers: this.headers({ accept: "text/event-stream", ...(cursor !== undefined ? { "last-event-id": cursor } : {}) }),
134
+ signal,
135
+ }));
136
+ const shared = this.shared;
137
+ const wanted = names && new Set(names);
138
+ return new EventStream((_name, live, signal) => shared.subscribe({ wanted, live, onGap: opts.onGap, reconnectMs: opts.reconnectMs }, signal), [""], opts.signal);
110
139
  }
111
140
  /**
112
141
  * Finished Turns of one Computer, several or all, as they end: who sent the work (`messages`), what the agent said
@@ -124,28 +153,28 @@ export class A12sClient {
124
153
  if (opts.after !== undefined)
125
154
  q.set("after", String(opts.after));
126
155
  const qs = q.toString();
127
- return this.request("GET", `/v1/computers/${enc(computer)}/inbox${qs ? "?" + qs : ""}`);
156
+ return this.request("GET", `/v1/agent-computers/${enc(computer)}/inbox${qs ? "?" + qs : ""}`);
128
157
  }
129
158
  message(computer, id) {
130
- return this.request("GET", `/v1/computers/${enc(computer)}/inbox/${enc(id)}`);
159
+ return this.request("GET", `/v1/agent-computers/${enc(computer)}/inbox/${enc(id)}`);
131
160
  }
132
161
  /** Withdraws a pending Message; a 409 A12sError names its state if it is too late. */
133
162
  withdraw(computer, id) {
134
- return this.request("DELETE", `/v1/computers/${enc(computer)}/inbox/${enc(id)}`);
163
+ return this.request("DELETE", `/v1/agent-computers/${enc(computer)}/inbox/${enc(id)}`);
135
164
  }
136
165
  /** Interrupts a Turn (default: the active one). The Turn ends `interrupted`; nothing is replayed. */
137
166
  async stop(computer, turn) {
138
167
  const id = turn ?? (await this.computer(computer)).activeTurn;
139
168
  if (id)
140
- await this.request("POST", `/v1/computers/${enc(computer)}/turns/${enc(id)}/stop`, {});
169
+ await this.request("POST", `/v1/agent-computers/${enc(computer)}/turns/${enc(id)}/stop`, {});
141
170
  }
142
171
  /** Stops the Computer's processes and keeps its disks, session and inbox. */
143
172
  async sleep(computer) {
144
- await this.request("POST", `/v1/computers/${enc(computer)}/power`, { state: "asleep" });
173
+ await this.request("POST", `/v1/agent-computers/${enc(computer)}/power`, { state: "asleep" });
145
174
  }
146
175
  /** Starts a sleeping Computer; its session resumes on the next Turn. */
147
176
  async wake(computer) {
148
- await this.request("POST", `/v1/computers/${enc(computer)}/power`, { state: "awake" });
177
+ await this.request("POST", `/v1/agent-computers/${enc(computer)}/power`, { state: "awake" });
149
178
  }
150
179
  async services() {
151
180
  return (await this.request("GET", "/v1/services")).services ?? [];
@@ -165,7 +194,7 @@ export class A12sClient {
165
194
  async deliveredIn(computer, id, signal) {
166
195
  // Every subscription starts with a snapshot, so resubscribing after a drop never misses the delivery.
167
196
  while (!signal?.aborted) {
168
- for await (const ev of this.stream(computer, `/v1/computers/${enc(computer)}/inbox/${enc(id)}/events`, undefined, {
197
+ for await (const ev of this.stream(computer, `/v1/agent-computers/${enc(computer)}/inbox/${enc(id)}/events`, undefined, {
169
198
  signal,
170
199
  once: true,
171
200
  })) {
@@ -214,7 +243,7 @@ export class A12sClient {
214
243
  continue;
215
244
  }
216
245
  if (!res.ok) {
217
- const err = new A12sError(res.status, parseError(res.status, await res.text()));
246
+ const err = apiError(res.status, parseError(res.status, await res.text()));
218
247
  // Asleep or starting: the journal comes back with the Computer.
219
248
  if (res.status !== 503 && res.status !== 502)
220
249
  throw err;
@@ -285,6 +314,188 @@ export class A12sClient {
285
314
  }
286
315
  }
287
316
  }
317
+ /** One GET /v1/events connection, open while anything subscribes: watching fifty agent computers takes one. */
318
+ class SharedEvents {
319
+ open;
320
+ subs = new Set();
321
+ conn;
322
+ live = false;
323
+ constructor(open) {
324
+ this.open = open;
325
+ }
326
+ async *subscribe(sub, signal) {
327
+ const queue = [];
328
+ let failure;
329
+ let wake;
330
+ const notify = () => {
331
+ const w = wake;
332
+ wake = undefined;
333
+ w?.();
334
+ };
335
+ const s = {
336
+ ...sub,
337
+ push: (ev) => {
338
+ queue.push(ev);
339
+ notify();
340
+ },
341
+ fail: (error) => {
342
+ failure ??= { error };
343
+ notify();
344
+ },
345
+ };
346
+ signal.addEventListener("abort", notify, { once: true });
347
+ this.add(s);
348
+ try {
349
+ for (;;) {
350
+ if (queue.length) {
351
+ yield queue.shift();
352
+ continue;
353
+ }
354
+ if (failure)
355
+ throw failure.error;
356
+ if (signal.aborted)
357
+ return;
358
+ await new Promise((r) => (wake = r));
359
+ }
360
+ }
361
+ finally {
362
+ signal.removeEventListener("abort", notify);
363
+ this.remove(s);
364
+ }
365
+ }
366
+ add(s) {
367
+ this.subs.add(s);
368
+ if (!this.conn) {
369
+ const conn = new AbortController();
370
+ this.conn = conn;
371
+ this.live = false;
372
+ void this.run(conn);
373
+ }
374
+ else if (this.live) {
375
+ s.live();
376
+ }
377
+ }
378
+ remove(s) {
379
+ this.subs.delete(s);
380
+ if (this.subs.size === 0)
381
+ this.stop();
382
+ }
383
+ stop() {
384
+ this.conn?.abort();
385
+ this.conn = undefined;
386
+ this.live = false;
387
+ }
388
+ async run(conn) {
389
+ const { signal } = conn;
390
+ let cursor;
391
+ try {
392
+ while (!signal.aborted) {
393
+ let res;
394
+ try {
395
+ res = await this.open(cursor, signal);
396
+ }
397
+ catch (err) {
398
+ if (signal.aborted)
399
+ return;
400
+ if (!isNetworkError(err))
401
+ throw err;
402
+ }
403
+ if (res && !res.ok) {
404
+ const err = apiError(res.status, parseError(res.status, await res.text()));
405
+ if (res.status !== 502 && res.status !== 503)
406
+ throw err;
407
+ res = undefined;
408
+ }
409
+ if (res) {
410
+ if (!res.body)
411
+ throw new A12sStreamError("GET /v1/events: response has no body");
412
+ const frames = readSSE(res.body);
413
+ try {
414
+ for (;;) {
415
+ let r;
416
+ try {
417
+ r = await frames.next();
418
+ }
419
+ catch {
420
+ break;
421
+ }
422
+ if (r.done)
423
+ break;
424
+ cursor = this.frame(r.value, cursor);
425
+ }
426
+ }
427
+ finally {
428
+ await frames.return(undefined);
429
+ res.body.cancel().catch(() => { });
430
+ }
431
+ }
432
+ await sleep(this.reconnectMs(), signal);
433
+ }
434
+ }
435
+ catch (err) {
436
+ if (signal.aborted)
437
+ return;
438
+ const subs = [...this.subs];
439
+ if (this.conn === conn)
440
+ this.stop();
441
+ for (const s of subs)
442
+ s.fail(err);
443
+ }
444
+ }
445
+ /** Delivers one frame and returns the cursor to resume after. */
446
+ frame(f, cursor) {
447
+ if (f.event === "head") {
448
+ const id = parseJSON(f.data)?.id;
449
+ this.live = true;
450
+ for (const s of this.subs)
451
+ s.live();
452
+ return typeof id === "string" && id ? id : cursor;
453
+ }
454
+ if (f.event === "gap") {
455
+ const data = cursor !== undefined ? { lastEventId: cursor } : {};
456
+ const gap = { v: 1, computer: "", session: "", turn: "", seq: 0, ts: new Date().toISOString(), type: "gap", data };
457
+ for (const s of this.subs) {
458
+ if (s.onGap === "throw")
459
+ s.fail(new A12sGapError(data));
460
+ else
461
+ s.push(gap);
462
+ }
463
+ return cursor;
464
+ }
465
+ if (f.event || !f.data)
466
+ return cursor;
467
+ let ev;
468
+ try {
469
+ ev = JSON.parse(f.data);
470
+ }
471
+ catch (err) {
472
+ throw new A12sStreamError(`malformed event after ${cursor ?? "(start)"}: ${f.data.slice(0, 120)}`, { cause: err });
473
+ }
474
+ for (const s of this.subs)
475
+ if (wanted(s.wanted, ev))
476
+ s.push(ev);
477
+ return f.id ?? cursor;
478
+ }
479
+ reconnectMs() {
480
+ let ms = 1000;
481
+ for (const s of this.subs)
482
+ if (s.reconnectMs !== undefined)
483
+ ms = Math.min(ms, s.reconnectMs);
484
+ return ms;
485
+ }
486
+ }
487
+ function agentComputerOf(ev) {
488
+ return ev.agentComputer || ev.computer;
489
+ }
490
+ function wanted(names, ev) {
491
+ if (!names)
492
+ return true;
493
+ if (ev.type === "fleet.changed")
494
+ return (ev.data?.names ?? []).some((n) => names.has(n));
495
+ if (ev.type === "service.phase")
496
+ return names.has(ev.data?.service);
497
+ return names.has(agentComputerOf(ev));
498
+ }
288
499
  /** Events of one or more Computers, merged in arrival order. Iterate it with `for await`. */
289
500
  export class EventStream {
290
501
  source;
@@ -442,7 +653,8 @@ class TurnBuilder {
442
653
  add(ev) {
443
654
  if (!ev.turn)
444
655
  return undefined;
445
- const key = `${ev.computer}\u0000${ev.turn}`;
656
+ const name = agentComputerOf(ev);
657
+ const key = `${name}\u0000${ev.turn}`;
446
658
  let t = this.open.get(key);
447
659
  if (!t)
448
660
  this.open.set(key, (t = { text: "", afterTool: false, messages: [], tools: new Map() }));
@@ -470,7 +682,8 @@ class TurnBuilder {
470
682
  case "turn.finished":
471
683
  this.open.delete(key);
472
684
  return {
473
- computer: ev.computer,
685
+ agentComputer: name,
686
+ computer: name,
474
687
  id: ev.turn,
475
688
  session: ev.session,
476
689
  status: ev.data.status,
@@ -479,6 +692,7 @@ class TurnBuilder {
479
692
  messages: t.messages,
480
693
  tools: [...t.tools.values()],
481
694
  usage: ev.data.usage,
695
+ ...(ev.data.tokens ? { tokens: ev.data.tokens } : {}),
482
696
  ...(t.startedAt ? { startedAt: t.startedAt } : {}),
483
697
  finishedAt: ev.ts,
484
698
  };
@@ -0,0 +1,160 @@
1
+ import { A12sClient, type A12sClientOptions, type EventsOptions, TurnStream, type EventStream, type PromptOptions, type PromptResult, type SharedEventsOptions } from "./client.js";
2
+ import type { AgentComputerData, AgentComputerSpec, Change, ComputerStatus, CredentialInfo, EnqueueRequest, EnqueueResponse, FleetSpec, InboxListOptions, InboxListResponse, MergePatch, Message, ServiceStatus, StatusResponse, Token, TokenAction, Turn } from "./types.js";
3
+ /** The Fleet as written, and its version for a conditional write. */
4
+ export interface FleetState {
5
+ version: number;
6
+ spec: FleetSpec;
7
+ /** What changed through the API since the last whole-Fleet write. */
8
+ drift: Change[];
9
+ }
10
+ export interface CreateOptions {
11
+ /** Overwrite an existing one instead of failing with A12sError code `exists`. */
12
+ replace?: boolean;
13
+ }
14
+ /**
15
+ * A running Fleet. Its agent computers, Services and tokens are in {@link agentComputers}, {@link services} and
16
+ * {@link tokens}.
17
+ *
18
+ * ```ts
19
+ * const fleet = await Fleet.connect();
20
+ * const hello = await fleet.agentComputers.create("hello", { instructions: "Be helpful" });
21
+ * await hello.prompt("hi");
22
+ * await hello.remove();
23
+ * ```
24
+ */
25
+ export declare class Fleet {
26
+ /** The lower-level client the handles use, for API calls that have no method here. */
27
+ readonly client: A12sClient;
28
+ readonly agentComputers: AgentComputers;
29
+ readonly services: Services;
30
+ readonly tokens: Tokens;
31
+ readonly credentials: Credentials;
32
+ /** No discovery: `url` and `token` as given, by default the page's own origin. */
33
+ constructor(opts?: A12sClientOptions);
34
+ /**
35
+ * In a browser: the Fleet behind the page's own origin, which is `a12s gateway` serving the page (no token
36
+ * needed). In Node: found like {@link connect} finds it. Pass `url` and `token` to reach anything else.
37
+ */
38
+ static connect(opts?: A12sClientOptions): Promise<Fleet>;
39
+ get url(): string;
40
+ get(): Promise<FleetState>;
41
+ /**
42
+ * Reads the Fleet, lets `fn` change it (in place, or by returning a new spec) and writes it back only if nobody
43
+ * wrote in between; otherwise it reads again and retries, up to 5 times. `spec.defaults` is always present.
44
+ */
45
+ replace(fn: (spec: FleetSpec) => FleetSpec | void | Promise<FleetSpec | void>): Promise<FleetState>;
46
+ /**
47
+ * Live events of the named agent computers, or (no argument) all of them, and the Fleet's own (`fleet.changed`,
48
+ * `agent_computer.phase`). Every stream of this Fleet shares one connection, which reconnects without losing
49
+ * events. `await stream.ready()` before sending work you want to see from the start; `close()` (or `break`) ends it.
50
+ * `turn` or `after` (one agent computer) replays from its journal instead.
51
+ */
52
+ events(agentComputers?: string | string[], opts?: EventsOptions): EventStream;
53
+ /** Finished Turns of the named agent computers, or all of them, as they end. */
54
+ turns(agentComputers?: string | string[], opts?: SharedEventsOptions): TurnStream;
55
+ /** Every agent computer and Service: phase, session, active Turn, inbox, power. */
56
+ status(): Promise<StatusResponse>;
57
+ }
58
+ export declare class AgentComputers {
59
+ private readonly client;
60
+ constructor(client: A12sClient);
61
+ /** Adds an agent computer to the Fleet and starts it. Fails with code `exists` if the name is taken. */
62
+ create(name: string, spec?: AgentComputerSpec & CreateOptions): Promise<AgentComputer>;
63
+ /** Throws {@link NotFound} if there is none. */
64
+ get(name: string): Promise<AgentComputer>;
65
+ list(): Promise<AgentComputer[]>;
66
+ }
67
+ /** A handle on one agent computer. `spec`, `effective` and `status` are as of the last create, get, reload or update. */
68
+ export declare class AgentComputer {
69
+ private readonly client;
70
+ readonly name: string;
71
+ spec: AgentComputerSpec;
72
+ /** The spec with the Fleet's defaults merged in. */
73
+ effective: AgentComputerSpec;
74
+ status: ComputerStatus;
75
+ constructor(client: A12sClient, data: AgentComputerData);
76
+ private set;
77
+ private get path();
78
+ reload(): Promise<this>;
79
+ /** Changes the spec: a key set to null goes back to the Fleet's default. A busy one picks it up when idle. */
80
+ update(changes: MergePatch<AgentComputerSpec>): Promise<this>;
81
+ /** Removes it from the Fleet and stops it; its drives are kept. */
82
+ remove(): Promise<void>;
83
+ /** Sends a Message, waits for the Turn that delivers it and resolves when that Turn ends. */
84
+ prompt(text: string, opts?: PromptOptions): Promise<PromptResult>;
85
+ /** Puts a Message in the inbox and returns at once; it is delivered at the next idle moment. */
86
+ send(text: string, opts?: Omit<EnqueueRequest, "body">): Promise<EnqueueResponse>;
87
+ /** Its events, live, from the Fleet's shared connection; `turn` replays one Turn from its start instead. */
88
+ events(opts?: EventsOptions): EventStream;
89
+ /** Its last `limit` finished Turns, newest first. */
90
+ turns(opts: {
91
+ limit: number;
92
+ }): Promise<Turn[]>;
93
+ /** Its Turns as they end, from the Fleet's shared connection. */
94
+ turns(opts?: SharedEventsOptions & {
95
+ limit?: undefined;
96
+ }): TurnStream;
97
+ private pastTurns;
98
+ /** Stops its processes and keeps its disks, session and inbox. */
99
+ sleep(): Promise<void>;
100
+ wake(): Promise<void>;
101
+ /** Interrupts a Turn (default: the active one). */
102
+ stop(turn?: string): Promise<void>;
103
+ inbox(opts?: InboxListOptions): Promise<InboxListResponse>;
104
+ message(id: string): Promise<Message>;
105
+ /** Withdraws a pending Message; a 409 A12sError names its state if it is too late. */
106
+ withdraw(id: string): Promise<Message>;
107
+ }
108
+ export declare class Services {
109
+ private readonly client;
110
+ constructor(client: A12sClient);
111
+ /** Adds a Service to the Fleet. Fails with code `exists` if the name is taken. */
112
+ create(name: string, spec?: Record<string, unknown> & CreateOptions): Promise<Service>;
113
+ /** Throws {@link NotFound} if there is none. */
114
+ get(name: string): Promise<Service>;
115
+ list(): Promise<Service[]>;
116
+ }
117
+ /** A handle on one Service. `spec` and `status` are as of the last create, get, reload, update or power change. */
118
+ export declare class Service {
119
+ private readonly client;
120
+ readonly name: string;
121
+ status: ServiceStatus;
122
+ constructor(client: A12sClient, status: ServiceStatus);
123
+ /** As written in the Fleet. */
124
+ get spec(): Record<string, unknown>;
125
+ private get path();
126
+ reload(): Promise<this>;
127
+ /** Changes the spec as a JSON merge patch: a key set to null is removed. */
128
+ update(changes: Record<string, unknown>): Promise<this>;
129
+ /** Removes it from the Fleet and stops it; its state drive is kept. */
130
+ remove(): Promise<void>;
131
+ sleep(): Promise<this>;
132
+ wake(): Promise<this>;
133
+ private power;
134
+ }
135
+ export declare class Tokens {
136
+ private readonly client;
137
+ constructor(client: A12sClient);
138
+ /**
139
+ * A token for another program; owner only. The secret is in the result once. With `prefix` it may do everything
140
+ * the owner can for names that start with it, and nothing outside; otherwise `actions` on `agentComputers`.
141
+ */
142
+ create(name: string, opts: {
143
+ prefix: string;
144
+ } | {
145
+ actions: TokenAction[];
146
+ agentComputers?: string[];
147
+ }): Promise<Token>;
148
+ list(): Promise<Token[]>;
149
+ remove(name: string): Promise<void>;
150
+ }
151
+ /** Credentials by name: set once, then name them in a spec's `credentials`. Values are never read back. */
152
+ export declare class Credentials {
153
+ private readonly client;
154
+ constructor(client: A12sClient);
155
+ /** Stores the value on the Controller; agent computers that use it get the new value at once. */
156
+ set(name: string, value: string): Promise<CredentialInfo>;
157
+ list(): Promise<CredentialInfo[]>;
158
+ /** Fails with A12sError code `in_use` while an agent computer or Service uses it. */
159
+ remove(name: string): Promise<void>;
160
+ }