@1kbirds/chidori 3.6.0 → 3.7.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/src/index.ts CHANGED
@@ -7,10 +7,25 @@
7
7
  import type { SignalSender } from "./agent.js";
8
8
 
9
9
  export type {
10
+ ActorHandle,
11
+ ActorMessage,
12
+ ActorOutcome,
13
+ ActorOutcomeStatus,
14
+ ActorRestartStrategy,
15
+ Actors,
16
+ ActorStatus,
17
+ ActorStillRunning,
10
18
  AgentFunction,
11
19
  AgentJson,
20
+ AgentOutput,
21
+ AppData,
22
+ BranchOptions,
23
+ BranchOutcome,
24
+ BranchStatus,
25
+ BranchVariant,
12
26
  CacheTtl,
13
27
  Chidori,
28
+ ChidoriUtil,
14
29
  CompactOptions,
15
30
  Context,
16
31
  Conversation,
@@ -18,22 +33,31 @@ export type {
18
33
  ConversationOptions,
19
34
  ConversationTurn,
20
35
  DatePolicy,
36
+ DetachedAgentHandle,
37
+ DetachedAgentOutcome,
38
+ DetachedAgents,
39
+ DetachedAgentStatus,
21
40
  InputOptions,
41
+ JoinActorOptions,
22
42
  JsonObject,
23
43
  JsonSchema,
24
44
  LlmResponseJson,
45
+ LogFields,
25
46
  MapSetSnapshotPolicy,
26
- MemoryAction,
47
+ MemoryStore,
27
48
  ParallelOptions,
28
49
  PromptOptions,
29
50
  PromptStreamType,
30
51
  RandomPolicy,
52
+ ReceiveOptions,
31
53
  RetryOptions,
32
54
  RuntimePolicyConfig,
33
55
  Signal,
34
56
  SignalOptions,
35
57
  SignalSender,
36
58
  SignalTimeout,
59
+ SpawnActorOptions,
60
+ SpawnAgentOptions,
37
61
  ToolDefinition,
38
62
  ToolFunction,
39
63
  TryCallResult,
@@ -256,7 +280,7 @@ export class Session {
256
280
  public pendingSignalName: string | null = null,
257
281
  /**
258
282
  * The full awaited name set when paused on a signal listen point: `[name]`
259
- * for `chidori.signal(name)`, the listen set for `chidori.signalAny(names)`.
283
+ * for `chidori.signal(name)`, the listen set for the fan-in `chidori.signal(names)`.
260
284
  * Empty for non-signal states.
261
285
  */
262
286
  public pendingSignalNames: string[] = [],
@@ -266,6 +290,18 @@ export class Session {
266
290
  * when it passes. `null` when the pause has no timeout.
267
291
  */
268
292
  public pendingSignalDeadline: string | null = null,
293
+ /**
294
+ * The artifact under review for an `input()` pause created with
295
+ * `{ details }` (a draft, a diff) — surface it so a human never approves
296
+ * blind. `null` when the pause carries no details.
297
+ */
298
+ public pendingDetails: string | null = null,
299
+ /**
300
+ * The durable run directory id (`.chidori/runs/<run_id>`) this session
301
+ * journals into. Deliberately distinct from the session id: `chidori
302
+ * resume <agent.ts> <run_id>` and `chidori trace <run_id>` take THIS id.
303
+ */
304
+ public runId: string | null = null,
269
305
  ) {}
270
306
 
271
307
  get ok(): boolean {
@@ -321,7 +357,7 @@ export type StreamEvent =
321
357
  }
322
358
  | {
323
359
  /**
324
- * The streamed run paused at a `signal()` / `signalAny()` listen point
360
+ * The streamed run paused at a `signal()` listen point
325
361
  * and stays live: the worker keeps supervising, and a delivered signal
326
362
  * (or the `timeoutMs` deadline) resumes it in-process — further events
327
363
  * follow on the same stream. Deliver with `client.signal`.
@@ -336,6 +372,109 @@ export type StreamEvent =
336
372
  }
337
373
  | { type: "done"; id: string; status: SessionStatus; output?: Json; error?: string };
338
374
 
375
+ /** Base class for every error the SDK throws. `catch (e) { if (e instanceof
376
+ * AgentClientError) ... }` covers HTTP failures, timeouts, and connection
377
+ * errors alike. */
378
+ export class AgentClientError extends Error {}
379
+
380
+ /**
381
+ * A non-2xx HTTP response. Carries the parsed `status` so callers can
382
+ * distinguish the server's documented semantics — e.g. for `client.signal`:
383
+ * 400 (empty name), 404 (unknown session), 409 (terminal run) — instead of
384
+ * string-matching the message.
385
+ */
386
+ export class HttpError extends AgentClientError {
387
+ constructor(
388
+ /** HTTP method of the failed request. */
389
+ readonly method: string,
390
+ /** Request path relative to the base URL. */
391
+ readonly path: string,
392
+ /** HTTP status code (400, 404, 409, 500, ...). */
393
+ readonly status: number,
394
+ /** Raw response body text (may be empty). */
395
+ readonly body: string,
396
+ /** The server's `error` field, when the body was `{ "error": ... }`. */
397
+ readonly detail: string | null = null,
398
+ ) {
399
+ super(`${method} ${path} failed: HTTP ${status}${detail || body ? `: ${detail ?? body}` : ""}`);
400
+ this.name = "HttpError";
401
+ }
402
+
403
+ static async fromResponse(method: string, path: string, resp: Response): Promise<HttpError> {
404
+ const body = await resp.text().catch(() => "");
405
+ let detail: string | null = null;
406
+ try {
407
+ const parsed = JSON.parse(body) as { error?: unknown };
408
+ if (typeof parsed.error === "string") detail = parsed.error;
409
+ } catch {
410
+ // not JSON — leave detail null
411
+ }
412
+ return new HttpError(method, path, resp.status, body, detail);
413
+ }
414
+ }
415
+
416
+ /** The request exceeded the client's `timeoutMs` without completing. */
417
+ export class TimeoutError extends AgentClientError {
418
+ constructor(
419
+ readonly method: string,
420
+ readonly path: string,
421
+ readonly timeoutMs: number,
422
+ ) {
423
+ super(`${method} ${path} timed out after ${timeoutMs}ms`);
424
+ this.name = "TimeoutError";
425
+ }
426
+ }
427
+
428
+ /** The request never produced an HTTP response (refused, reset, DNS, ...). */
429
+ export class ConnectionError extends AgentClientError {
430
+ constructor(
431
+ readonly method: string,
432
+ readonly path: string,
433
+ cause: unknown,
434
+ ) {
435
+ super(`${method} ${path} failed: ${cause instanceof Error ? cause.message : String(cause)}`, {
436
+ cause,
437
+ });
438
+ this.name = "ConnectionError";
439
+ }
440
+ }
441
+
442
+ /** Response statuses worth retrying on idempotent requests. */
443
+ const RETRYABLE_STATUS = new Set([429, 502, 503, 504]);
444
+
445
+ export interface AgentClientOptions {
446
+ /**
447
+ * Bearer token for a server running with `CHIDORI_API_KEY` set (the
448
+ * production posture — see docs/deployment.md). Sent as
449
+ * `Authorization: Bearer <apiKey>` on every request, including `stream()`.
450
+ * Omit against an unauthenticated loopback server.
451
+ */
452
+ apiKey?: string;
453
+ /**
454
+ * Extra headers merged into every request (after `apiKey`, so an explicit
455
+ * `Authorization` entry here wins). Escape hatch for proxies and custom
456
+ * auth schemes the `apiKey` option doesn't cover.
457
+ */
458
+ headers?: Record<string, string>;
459
+ /**
460
+ * Per-request timeout in milliseconds; `0` disables it. Defaults to
461
+ * 300 000 (5 minutes) — generous because `run()` executes the whole agent
462
+ * before responding, but finite so a hung server surfaces as a
463
+ * `TimeoutError` instead of blocking forever. For `stream()` the timeout
464
+ * covers connection establishment only, never an open event stream.
465
+ */
466
+ timeoutMs?: number;
467
+ /**
468
+ * How many times to retry **idempotent GET requests** after a connection
469
+ * error, timeout, or retryable status (429/502/503/504). Defaults to 2.
470
+ * POST requests are never retried — `run`/`resume`/`signal` are not
471
+ * idempotent, and a blind retry could execute an agent twice.
472
+ */
473
+ retries?: number;
474
+ /** Base delay between retries in milliseconds, doubling per attempt (default 250). */
475
+ retryDelayMs?: number;
476
+ }
477
+
339
478
  /**
340
479
  * HTTP client for an `chidori serve` instance.
341
480
  *
@@ -347,12 +486,28 @@ export type StreamEvent =
347
486
  * const cp = await session.checkpoint();
348
487
  * const replayed = await client.replay(cp); // zero LLM calls
349
488
  * ```
489
+ *
490
+ * Failures throw typed errors (all extending {@link AgentClientError}):
491
+ * {@link HttpError} with a `.status` for non-2xx responses,
492
+ * {@link TimeoutError} after `timeoutMs`, {@link ConnectionError} when no
493
+ * response arrived at all.
350
494
  */
351
495
  export class AgentClient {
352
496
  readonly baseUrl: string;
497
+ readonly timeoutMs: number;
498
+ readonly retries: number;
499
+ readonly retryDelayMs: number;
500
+ private readonly baseHeaders: Record<string, string>;
353
501
 
354
- constructor(baseUrl: string = "http://localhost:8080") {
502
+ constructor(baseUrl: string = "http://localhost:8080", options: AgentClientOptions = {}) {
355
503
  this.baseUrl = baseUrl.replace(/\/$/, "");
504
+ this.timeoutMs = options.timeoutMs ?? 300_000;
505
+ this.retries = options.retries ?? 2;
506
+ this.retryDelayMs = options.retryDelayMs ?? 250;
507
+ this.baseHeaders = {
508
+ ...(options.apiKey ? { Authorization: `Bearer ${options.apiKey}` } : {}),
509
+ ...(options.headers ?? {}),
510
+ };
356
511
  }
357
512
 
358
513
  async health(): Promise<Json> {
@@ -472,15 +627,45 @@ export class AgentClient {
472
627
  * events (`prompt_start`, `prompt_delta`, `prompt_end`), then a final
473
628
  * `done` event. Prompt events include `prompt_type` so UIs can filter
474
629
  * progress streams separately from final-answer streams.
630
+ *
631
+ * `options.policyProfile` mirrors `run()`: a built-in profile layered on
632
+ * the server policy with stricter-wins semantics for this session.
475
633
  */
476
- async *stream(input: Json): AsyncGenerator<StreamEvent, void, void> {
477
- const resp = await fetch(`${this.baseUrl}/sessions/stream`, {
478
- method: "POST",
479
- headers: { "Content-Type": "application/json", Accept: "text/event-stream" },
480
- body: JSON.stringify({ input }),
481
- });
634
+ async *stream(
635
+ input: Json,
636
+ options?: { policyProfile?: PolicyProfile },
637
+ ): AsyncGenerator<StreamEvent, void, void> {
638
+ // The timeout covers connection establishment (until response headers
639
+ // arrive), not the open event stream — a healthy run may stream for a
640
+ // long time between events.
641
+ const controller = new AbortController();
642
+ const timer =
643
+ this.timeoutMs > 0 ? setTimeout(() => controller.abort(), this.timeoutMs) : null;
644
+ const body: Record<string, unknown> = { input };
645
+ if (options?.policyProfile) {
646
+ body.policy_profile = options.policyProfile;
647
+ }
648
+ let resp: Response;
649
+ try {
650
+ resp = await fetch(`${this.baseUrl}/sessions/stream`, {
651
+ method: "POST",
652
+ headers: {
653
+ ...this.baseHeaders,
654
+ "Content-Type": "application/json",
655
+ Accept: "text/event-stream",
656
+ },
657
+ body: JSON.stringify(body),
658
+ signal: controller.signal,
659
+ });
660
+ } catch (err) {
661
+ throw controller.signal.aborted
662
+ ? new TimeoutError("POST", "/sessions/stream", this.timeoutMs)
663
+ : new ConnectionError("POST", "/sessions/stream", err);
664
+ } finally {
665
+ if (timer) clearTimeout(timer);
666
+ }
482
667
  if (!resp.ok || !resp.body) {
483
- throw new Error(`stream request failed: ${resp.status}`);
668
+ throw await HttpError.fromResponse("POST", "/sessions/stream", resp);
484
669
  }
485
670
 
486
671
  // Minimal SSE parser — just enough for the events our server emits.
@@ -517,12 +702,15 @@ export class AgentClient {
517
702
  (data.pending_signal_name as string | undefined) ?? null,
518
703
  (data.pending_signal_names as string[] | undefined) ?? [],
519
704
  (data.pending_signal_deadline as string | undefined) ?? null,
705
+ (data.pending_details as string | undefined) ?? null,
706
+ (data.run_id as string | undefined) ?? null,
520
707
  );
521
708
  }
522
709
 
523
710
  private async getJSON(path: string): Promise<unknown> {
524
- const resp = await fetch(this.baseUrl + path);
525
- if (!resp.ok) throw await httpError(resp);
711
+ // GETs are idempotent: retry connection failures, timeouts, and
712
+ // retryable statuses with exponential backoff.
713
+ const resp = await this.request("GET", path, undefined, this.retries);
526
714
  return await resp.json();
527
715
  }
528
716
 
@@ -534,19 +722,67 @@ export class AgentClient {
534
722
  path: string,
535
723
  body: unknown,
536
724
  ): Promise<{ status: number; data: Record<string, unknown> }> {
537
- const resp = await fetch(this.baseUrl + path, {
538
- method: "POST",
539
- headers: { "Content-Type": "application/json" },
540
- body: JSON.stringify(body),
541
- });
542
- if (!resp.ok) throw await httpError(resp);
725
+ // POSTs are never retried: run/resume/signal are not idempotent.
726
+ const resp = await this.request("POST", path, body, 0);
543
727
  return { status: resp.status, data: (await resp.json()) as Record<string, unknown> };
544
728
  }
729
+
730
+ /**
731
+ * One HTTP exchange with timeout and (for idempotent requests) retries.
732
+ * Resolves with a 2xx `Response`; throws {@link HttpError},
733
+ * {@link TimeoutError}, or {@link ConnectionError} otherwise.
734
+ */
735
+ private async request(
736
+ method: "GET" | "POST",
737
+ path: string,
738
+ body: unknown,
739
+ retries: number,
740
+ ): Promise<Response> {
741
+ let lastError: AgentClientError | null = null;
742
+ for (let attempt = 0; attempt <= retries; attempt++) {
743
+ if (attempt > 0) {
744
+ await sleep(this.retryDelayMs * 2 ** (attempt - 1));
745
+ }
746
+ const controller = new AbortController();
747
+ const timer =
748
+ this.timeoutMs > 0 ? setTimeout(() => controller.abort(), this.timeoutMs) : null;
749
+ try {
750
+ let resp: Response;
751
+ try {
752
+ resp = await fetch(this.baseUrl + path, {
753
+ method,
754
+ signal: controller.signal,
755
+ headers:
756
+ body !== undefined
757
+ ? { ...this.baseHeaders, "Content-Type": "application/json" }
758
+ : this.baseHeaders,
759
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
760
+ });
761
+ } catch (err) {
762
+ throw controller.signal.aborted
763
+ ? new TimeoutError(method, path, this.timeoutMs)
764
+ : new ConnectionError(method, path, err);
765
+ }
766
+ if (!resp.ok) throw await HttpError.fromResponse(method, path, resp);
767
+ return resp;
768
+ } catch (err) {
769
+ const retryable =
770
+ err instanceof TimeoutError ||
771
+ err instanceof ConnectionError ||
772
+ (err instanceof HttpError && RETRYABLE_STATUS.has(err.status));
773
+ if (!retryable || attempt === retries) throw err;
774
+ lastError = err as AgentClientError;
775
+ } finally {
776
+ if (timer) clearTimeout(timer);
777
+ }
778
+ }
779
+ // Unreachable: the loop either returns or throws on the last attempt.
780
+ throw lastError ?? new ConnectionError(method, path, "retries exhausted");
781
+ }
545
782
  }
546
783
 
547
- async function httpError(resp: Response): Promise<Error> {
548
- const text = await resp.text().catch(() => "");
549
- return new Error(`HTTP ${resp.status}: ${text}`);
784
+ function sleep(ms: number): Promise<void> {
785
+ return new Promise((resolve) => setTimeout(resolve, ms));
550
786
  }
551
787
 
552
788
  function parseSseFrame(frame: string): StreamEvent | null {