@inixiative/agent-session 0.3.1 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inixiative/agent-session",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "description": "Drive coding-agent CLIs as persistent, streaming, event-captured sessions over interchangeable transports, with primed decision sessions, subscription pools, routing, limit polling and continuity.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -18,7 +18,7 @@
18
18
  "prepublishOnly": "bun run check",
19
19
  "measure:primed": "bun scripts/measure-primed.ts",
20
20
  "lint": "biome check .",
21
- "prepare": "lefthook install"
21
+ "prepare": "if git rev-parse --git-dir >/dev/null 2>&1; then lefthook install; fi"
22
22
  },
23
23
  "keywords": [
24
24
  "claude-code",
@@ -34,9 +34,12 @@
34
34
  "eval"
35
35
  ],
36
36
  "license": "MIT",
37
+ "dependencies": {
38
+ "@inixiative/json-rules": "^3.4.1"
39
+ },
37
40
  "devDependencies": {
38
41
  "@biomejs/biome": "2.5.2",
39
- "@inixiative/config": "^0.5.41",
42
+ "@inixiative/config": "^0.5.62",
40
43
  "@types/bun": "1.4.2",
41
44
  "lefthook": "2.1.9",
42
45
  "typescript": "6.0.3"
package/src/index.ts CHANGED
@@ -87,15 +87,25 @@ export {
87
87
  export {
88
88
  assessCandidate,
89
89
  type CandidateRequest,
90
+ DEFAULT_STRATEGY,
90
91
  type ExclusionReason,
92
+ type OrderBy,
93
+ type OrderingSpec,
94
+ type OrderingTerm,
91
95
  type RankedCandidate,
92
96
  type RepositoryRoute,
93
- type RoutingMode,
97
+ type ResolvedOrdering,
98
+ ROUTING_STRATEGIES,
99
+ type RoutingField,
94
100
  type RoutingRequest,
101
+ type RoutingStrategy,
95
102
  rankCandidates,
96
103
  rankSubscriptionAccounts,
97
104
  repositoryIdentity,
105
+ resolveOrdering,
106
+ type SortDir,
98
107
  type SubscriptionCandidate,
108
+ type UnknownUtilizationPolicy,
99
109
  } from './routing';
100
110
  // Transports: how a session reaches its model, with declared capabilities.
101
111
  export {
package/src/pool.ts CHANGED
@@ -20,8 +20,9 @@ import { type LimitSnapshot, mergeLimits } from './limits';
20
20
  import { probeClaudeLimits, probeCodexLimits } from './limits-probe';
21
21
  import {
22
22
  type ExclusionReason,
23
+ type OrderingSpec,
23
24
  type RankedCandidate,
24
- type RoutingMode,
25
+ type ResolvedOrdering,
25
26
  rankCandidates,
26
27
  type SubscriptionCandidate,
27
28
  } from './routing';
@@ -48,6 +49,8 @@ export interface InstanceConfig {
48
49
  readonly models?: Readonly<Record<string, readonly string[]>>;
49
50
  /** Concurrent leases. Default 1. */
50
51
  readonly concurrencyLimit?: number;
52
+ /** Lower serves first under a priority ordering. Default 0. */
53
+ readonly priority?: number;
51
54
  readonly enabled?: boolean;
52
55
  readonly authentication?: 'subscription' | 'api-key';
53
56
  /** Session config merged into every session opened on this instance (bin, spawn, SDK query…). */
@@ -60,7 +63,9 @@ export interface PoolRequest {
60
63
  readonly effort?: string;
61
64
  readonly organizationId?: string;
62
65
  readonly preferredInstanceId?: string;
63
- readonly mode?: RoutingMode;
66
+ readonly ordering?: OrderingSpec;
67
+ /** Only the preferred instance may serve. Requires preferredInstanceId. */
68
+ readonly pinned?: boolean;
64
69
  /** Restrict to these transports (e.g. only resumable ones). */
65
70
  readonly transports?: readonly TransportKind[];
66
71
  readonly excludeInstanceIds?: readonly string[];
@@ -90,8 +95,8 @@ export type PoolEvent =
90
95
  readonly leaseId: string;
91
96
  readonly instanceId: string;
92
97
  readonly transport: PooledTransport;
93
- readonly mode: RoutingMode;
94
- readonly utilizationPercent: number;
98
+ readonly ordering: ResolvedOrdering;
99
+ readonly utilizationPercent: number | null;
95
100
  readonly rank: number;
96
101
  }
97
102
  | { readonly type: 'released'; readonly leaseId: string; readonly instanceId: string }
@@ -288,6 +293,7 @@ export class SubscriptionPool {
288
293
  rank(request: PoolRequest): {
289
294
  candidates: RankedCandidate[];
290
295
  excluded: Record<string, ExclusionReason>;
296
+ ordering: ResolvedOrdering;
291
297
  } {
292
298
  const now = this._now();
293
299
  const effort = request.effort ?? 'default';
@@ -304,6 +310,7 @@ export class SubscriptionPool {
304
310
  models: state.config.models ?? { [request.model]: [effort] },
305
311
  activeRuns: state.leases.size,
306
312
  concurrencyLimit: state.config.concurrencyLimit ?? 1,
313
+ priority: state.config.priority ?? 0,
307
314
  windows: (limits?.windows ?? []).map((w) => ({
308
315
  usedPercent: w.usedPercent,
309
316
  reservedPercent: reserved,
@@ -323,7 +330,8 @@ export class SubscriptionPool {
323
330
  effort,
324
331
  now,
325
332
  maximumObservationAgeMs: this._maxAge,
326
- mode: request.mode,
333
+ ordering: request.ordering,
334
+ ...(request.pinned !== undefined ? { pinned: request.pinned } : {}),
327
335
  organizationId: request.organizationId,
328
336
  preferredId: request.preferredInstanceId,
329
337
  },
@@ -333,7 +341,7 @@ export class SubscriptionPool {
333
341
 
334
342
  /** Lease the best instance now, or throw PoolExhaustedError with every exclusion reason. */
335
343
  acquire(request: PoolRequest): Lease {
336
- const { candidates, excluded } = this.rank(request);
344
+ const { candidates, excluded, ordering } = this.rank(request);
337
345
  const best = candidates[0];
338
346
  if (!best) {
339
347
  this._emit({ type: 'exhausted', runtime: request.runtime, model: request.model, excluded });
@@ -346,12 +354,7 @@ export class SubscriptionPool {
346
354
  excluded,
347
355
  );
348
356
  }
349
- return this._lease(
350
- this._state(best.accountId),
351
- request.mode ?? 'quartile-balanced',
352
- best.utilizationPercent,
353
- 0,
354
- );
357
+ return this._lease(this._state(best.accountId), ordering, best.utilizationPercent, 0);
355
358
  }
356
359
 
357
360
  /** Refresh stale limits, lease an instance and open a session on it. The lease ends with the session. */
@@ -429,7 +432,7 @@ export class SubscriptionPool {
429
432
  );
430
433
  const allowed = new Set(shared.map((s) => s.config.id));
431
434
  await this._refreshStale(request, allowed);
432
- const { candidates, excluded } = this.rank({
435
+ const { candidates, excluded, ordering } = this.rank({
433
436
  ...request,
434
437
  excludeInstanceIds: [
435
438
  ...(request.excludeInstanceIds ?? []),
@@ -451,12 +454,7 @@ export class SubscriptionPool {
451
454
  );
452
455
  binding.session?.kill();
453
456
  binding.lease?.release();
454
- const lease = this._lease(
455
- this._state(best.accountId),
456
- request.mode ?? 'quartile-balanced',
457
- best.utilizationPercent,
458
- 0,
459
- );
457
+ const lease = this._lease(this._state(best.accountId), ordering, best.utilizationPercent, 0);
460
458
  try {
461
459
  const session = this._session(
462
460
  lease,
@@ -566,8 +564,8 @@ export class SubscriptionPool {
566
564
  }
567
565
  private _lease(
568
566
  state: InstanceState,
569
- mode: RoutingMode,
570
- utilizationPercent: number,
567
+ ordering: ResolvedOrdering,
568
+ utilizationPercent: number | null,
571
569
  rank: number,
572
570
  ): Lease {
573
571
  const leaseId = crypto.randomUUID();
@@ -593,7 +591,7 @@ export class SubscriptionPool {
593
591
  leaseId,
594
592
  instanceId: state.config.id,
595
593
  transport: state.config.transport,
596
- mode,
594
+ ordering,
597
595
  utilizationPercent,
598
596
  rank,
599
597
  });
package/src/routing.ts CHANGED
@@ -8,18 +8,83 @@
8
8
  // does not mint authority, read credentials or call a model, and a routing
9
9
  // preference never widens a grant (organization, runtime, model, billing).
10
10
  //
11
- // Strategies:
12
- // quartile-balanced (default) lowest usage quartile first; the preferred
13
- // (owning) instance wins within its quartile.
14
- // owner-first the preferred instance while eligible, then
15
- // spillover by utilization.
16
- // pinned the preferred instance only; otherwise none.
11
+ // Ordering is an ordered list of terms over the fields projected onto each
12
+ // eligible candidate, in `@inixiative/json-rules`' `OrderBy` vocabulary.
13
+ // Named strategies are presets over that list; a caller may pass its own terms
14
+ // instead. `id` is always the final term, so ranking is total and deterministic.
17
15
  //
18
- // Unknown, stale, future or reset-crossing limit observations exclude an
19
- // instance: routing never guesses capacity.
16
+ // Eligibility is separate from ordering: `pinned` restricts which instances may
17
+ // serve at all, and unknown, stale, future or reset-crossing limit observations
18
+ // mean routing has no capacity reading for an instance. By default that excludes
19
+ // it — routing never guesses capacity.
20
20
  // ---------------------------------------------------------------------------
21
21
 
22
- export type RoutingMode = 'quartile-balanced' | 'owner-first' | 'pinned';
22
+ import type { OrderBy, SortDir } from '@inixiative/json-rules';
23
+
24
+ export type { OrderBy, SortDir };
25
+
26
+ export type RoutingField = 'priority' | 'utilization' | 'quartile' | 'headroom' | 'preferred';
27
+
28
+ export interface OrderingTerm {
29
+ readonly field: RoutingField;
30
+ readonly dir?: SortDir;
31
+ }
32
+
33
+ export type RoutingStrategy =
34
+ | 'quartile-balanced'
35
+ | 'owner-first'
36
+ | 'priority-balanced'
37
+ | 'priority-strict'
38
+ | 'least-used';
39
+
40
+ export type OrderingSpec = RoutingStrategy | readonly OrderingTerm[];
41
+
42
+ /** Every term carries a direction. Assignable to json-rules' `OrderBy`. */
43
+ export type ResolvedOrdering = { field: RoutingField; dir: SortDir }[];
44
+
45
+ /** The direction that puts the better candidate first when a term omits one. */
46
+ const NATURAL_DIRECTION: Record<RoutingField, SortDir> = {
47
+ priority: 'asc',
48
+ utilization: 'asc',
49
+ quartile: 'asc',
50
+ headroom: 'desc',
51
+ preferred: 'desc',
52
+ };
53
+
54
+ export const ROUTING_STRATEGIES: Record<RoutingStrategy, readonly OrderingTerm[]> = {
55
+ 'quartile-balanced': [{ field: 'quartile' }, { field: 'preferred' }, { field: 'utilization' }],
56
+ 'owner-first': [{ field: 'preferred' }, { field: 'utilization' }],
57
+ // Load decides between tiers that differ by a quartile; priority decides within one.
58
+ 'priority-balanced': [
59
+ { field: 'quartile' },
60
+ { field: 'priority' },
61
+ { field: 'preferred' },
62
+ { field: 'utilization' },
63
+ ],
64
+ // Priority dominates: a lower tier serves only once every higher one is excluded.
65
+ 'priority-strict': [{ field: 'priority' }, { field: 'utilization' }],
66
+ 'least-used': [{ field: 'utilization' }],
67
+ };
68
+
69
+ export const DEFAULT_STRATEGY: RoutingStrategy = 'quartile-balanced';
70
+
71
+ const ROUTING_FIELDS = Object.keys(NATURAL_DIRECTION) as RoutingField[];
72
+
73
+ /** Resolve a strategy name or explicit term list into terms, each with a direction. */
74
+ export const resolveOrdering = (spec: OrderingSpec = DEFAULT_STRATEGY): ResolvedOrdering => {
75
+ if (typeof spec === 'string') {
76
+ const preset = ROUTING_STRATEGIES[spec];
77
+ if (!preset) throw Error(`Unknown routing strategy "${spec}"`);
78
+ return resolveOrdering(preset);
79
+ }
80
+ if (!spec.length) throw Error('Ordering requires at least one term');
81
+ return spec.map((term) => {
82
+ if (!ROUTING_FIELDS.includes(term.field)) throw Error(`Unknown ordering field "${term.field}"`);
83
+ if (term.dir !== undefined && term.dir !== 'asc' && term.dir !== 'desc')
84
+ throw Error(`Unknown sort direction "${term.dir}"`);
85
+ return { field: term.field, dir: term.dir ?? NATURAL_DIRECTION[term.field] };
86
+ });
87
+ };
23
88
 
24
89
  export interface RepositoryRoute {
25
90
  repository: string;
@@ -37,6 +102,8 @@ export interface SubscriptionCandidate {
37
102
  models: Readonly<Record<string, readonly string[]>>;
38
103
  activeRuns: number;
39
104
  concurrencyLimit: number;
105
+ /** Lower serves first. Absent is 0, so prioritized and unprioritized instances order together. */
106
+ priority?: number;
40
107
  /** All provider enforcement windows, plus locally retained reservations. `resetsAt: Infinity` means none reported. */
41
108
  windows: readonly {
42
109
  usedPercent: number | null;
@@ -50,6 +117,16 @@ export interface SubscriptionCandidate {
50
117
  unavailableUntil?: number;
51
118
  }
52
119
 
120
+ /**
121
+ * What to do with an instance routing has no usable capacity reading for.
122
+ *
123
+ * `exclude` (default) suits provider-enforced subscription windows, where an
124
+ * unread login may already be exhausted and sending work there fails the turn.
125
+ * `rank-last` suits capacity whose spend is bounded elsewhere (Kingdom gates on
126
+ * allocation policies), where a never-observed instance must stay usable.
127
+ */
128
+ export type UnknownUtilizationPolicy = 'exclude' | 'rank-last';
129
+
53
130
  export interface RoutingRequest {
54
131
  repository: string;
55
132
  organizationId?: string;
@@ -58,7 +135,9 @@ export interface RoutingRequest {
58
135
  effort: string;
59
136
  now: number;
60
137
  maximumObservationAgeMs: number;
61
- mode?: RoutingMode;
138
+ ordering?: OrderingSpec;
139
+ pinned?: boolean;
140
+ unknownUtilization?: UnknownUtilizationPolicy;
62
141
  }
63
142
 
64
143
  /** Repository resolution already done by the caller (or not needed). */
@@ -70,13 +149,19 @@ export interface CandidateRequest {
70
149
  effort: string;
71
150
  now: number;
72
151
  maximumObservationAgeMs: number;
73
- mode?: RoutingMode;
152
+ ordering?: OrderingSpec;
153
+ /** Only the preferred instance may serve; others are excluded as `not-pinned`. */
154
+ pinned?: boolean;
155
+ unknownUtilization?: UnknownUtilizationPolicy;
74
156
  }
75
157
 
76
158
  export interface RankedCandidate {
77
159
  accountId: string;
78
- utilizationPercent: number;
79
- quartile: number;
160
+ /** Null when no usable observation exists and the policy is `rank-last`. */
161
+ utilizationPercent: number | null;
162
+ quartile: number | null;
163
+ priority: number;
164
+ headroom: number;
80
165
  preferred: boolean;
81
166
  }
82
167
 
@@ -133,13 +218,14 @@ export function repositoryIdentity(remote: string): string {
133
218
  export function assessCandidate(
134
219
  request: CandidateRequest,
135
220
  account: SubscriptionCandidate,
136
- ): { excluded: ExclusionReason } | { utilizationPercent: number } {
221
+ ): { excluded: ExclusionReason } | { utilizationPercent: number | null } {
137
222
  if (
138
223
  !account.id ||
139
224
  !Number.isSafeInteger(account.activeRuns) ||
140
225
  account.activeRuns < 0 ||
141
226
  !Number.isSafeInteger(account.concurrencyLimit) ||
142
- account.concurrencyLimit < 1
227
+ account.concurrencyLimit < 1 ||
228
+ (account.priority !== undefined && !Number.isFinite(account.priority))
143
229
  )
144
230
  return { excluded: 'invalid' };
145
231
  if (!account.enabled) return { excluded: 'disabled' };
@@ -151,13 +237,16 @@ export function assessCandidate(
151
237
  return { excluded: 'organization' };
152
238
  if (account.runtime !== request.runtime) return { excluded: 'runtime' };
153
239
  if (!account.models[request.model]?.includes(request.effort)) return { excluded: 'model' };
154
- if (request.mode === 'pinned' && account.id !== request.preferredId)
155
- return { excluded: 'not-pinned' };
240
+ if (request.pinned && account.id !== request.preferredId) return { excluded: 'not-pinned' };
156
241
  if (account.unavailableUntil !== undefined && account.unavailableUntil > request.now)
157
242
  return { excluded: 'unavailable' };
158
243
  if (account.activeRuns >= account.concurrencyLimit) return { excluded: 'occupied' };
159
244
  if (account.blocked) return { excluded: 'blocked' };
160
- if (account.windows.length === 0) return { excluded: 'no-observation' };
245
+ const unreadable = request.unknownUtilization === 'rank-last' ? null : undefined;
246
+ if (account.windows.length === 0) {
247
+ if (unreadable === undefined) return { excluded: 'no-observation' };
248
+ return { utilizationPercent: null };
249
+ }
161
250
  const fractions: number[] = [];
162
251
  for (const window of account.windows) {
163
252
  if (
@@ -168,7 +257,7 @@ export function assessCandidate(
168
257
  !Number.isFinite(window.reservedPercent) ||
169
258
  window.reservedPercent < 0
170
259
  )
171
- return { excluded: 'invalid' };
260
+ return unreadable === undefined ? { excluded: 'invalid' } : { utilizationPercent: null };
172
261
  // resetsAt === Infinity: the provider reported no reset for this window, so no reset can have crossed the observation.
173
262
  if (
174
263
  !Number.isFinite(window.observedAt) ||
@@ -177,28 +266,50 @@ export function assessCandidate(
177
266
  (window.resetsAt !== Number.POSITIVE_INFINITY &&
178
267
  (!Number.isFinite(window.resetsAt) || window.resetsAt <= request.now))
179
268
  )
180
- return { excluded: 'stale-observation' };
269
+ return unreadable === undefined
270
+ ? { excluded: 'stale-observation' }
271
+ : { utilizationPercent: null };
181
272
  fractions.push(window.usedPercent + window.reservedPercent);
182
273
  }
183
274
  if (fractions.some((p) => p >= 100)) return { excluded: 'exhausted' };
184
275
  return { utilizationPercent: Math.max(...fractions) };
185
276
  }
186
277
 
278
+ const FIELD_VALUE: Record<RoutingField, (candidate: RankedCandidate) => number | null> = {
279
+ priority: (c) => c.priority,
280
+ utilization: (c) => c.utilizationPercent,
281
+ quartile: (c) => c.quartile,
282
+ headroom: (c) => c.headroom,
283
+ preferred: (c) => Number(c.preferred),
284
+ };
285
+
286
+ /** Nulls last in either direction: an unknown reading never outranks a known one. */
287
+ const compare = (a: number | null, b: number | null, dir: SortDir): number => {
288
+ if (a === null) return b === null ? 0 : 1;
289
+ if (b === null) return -1;
290
+ return dir === 'desc' ? b - a : a - b;
291
+ };
292
+
187
293
  /** Rank eligible candidates deterministically (ties broken by id). */
188
294
  export function rankCandidates(
189
295
  request: CandidateRequest,
190
296
  accounts: readonly SubscriptionCandidate[],
191
- ): { candidates: RankedCandidate[]; excluded: Record<string, ExclusionReason> } {
297
+ ): {
298
+ candidates: RankedCandidate[];
299
+ excluded: Record<string, ExclusionReason>;
300
+ ordering: ResolvedOrdering;
301
+ } {
192
302
  if (
193
303
  !Number.isFinite(request.now) ||
194
304
  !Number.isFinite(request.maximumObservationAgeMs) ||
195
305
  request.maximumObservationAgeMs <= 0
196
306
  )
197
307
  throw Error('Invalid observation policy');
198
- if (request.mode === 'pinned' && !request.preferredId)
308
+ if (request.pinned && !request.preferredId)
199
309
  throw Error('Pinned routing requires a preferred instance');
200
310
  if (new Set(accounts.map((a) => a.id)).size !== accounts.length)
201
311
  throw Error('Duplicate account identity');
312
+ const ordering = resolveOrdering(request.ordering);
202
313
  const excluded: Record<string, ExclusionReason> = {};
203
314
  const ranked = accounts.flatMap((account) => {
204
315
  const assessed = assessCandidate(request, account);
@@ -211,22 +322,21 @@ export function rankCandidates(
211
322
  {
212
323
  accountId: account.id,
213
324
  utilizationPercent,
214
- quartile: Math.floor(utilizationPercent / 25),
325
+ quartile: utilizationPercent === null ? null : Math.floor(utilizationPercent / 25),
326
+ priority: account.priority ?? 0,
327
+ headroom: account.concurrencyLimit - account.activeRuns,
215
328
  preferred: account.id === request.preferredId,
216
329
  },
217
330
  ];
218
331
  });
219
- ranked.sort((a, b) =>
220
- request.mode === 'owner-first' || request.mode === 'pinned'
221
- ? Number(b.preferred) - Number(a.preferred) ||
222
- a.utilizationPercent - b.utilizationPercent ||
223
- a.accountId.localeCompare(b.accountId)
224
- : a.quartile - b.quartile ||
225
- Number(b.preferred) - Number(a.preferred) ||
226
- a.utilizationPercent - b.utilizationPercent ||
227
- a.accountId.localeCompare(b.accountId),
228
- );
229
- return { candidates: ranked, excluded };
332
+ ranked.sort((a, b) => {
333
+ for (const { field, dir } of ordering) {
334
+ const order = compare(FIELD_VALUE[field](a), FIELD_VALUE[field](b), dir);
335
+ if (order !== 0) return order;
336
+ }
337
+ return a.accountId.localeCompare(b.accountId);
338
+ });
339
+ return { candidates: ranked, excluded, ordering };
230
340
  }
231
341
 
232
342
  /** The draft's repository-scoped entry point: resolve ownership, then rank. */
@@ -249,7 +359,7 @@ export function rankSubscriptionAccounts(
249
359
  throw Error('Incomplete repository routing rule');
250
360
  if (request.organizationId && request.organizationId !== route.organizationId)
251
361
  throw Error('Explicit organization conflicts with repository ownership');
252
- const { candidates } = rankCandidates(
362
+ const { candidates, ordering } = rankCandidates(
253
363
  {
254
364
  organizationId: route.organizationId,
255
365
  preferredId: route.preferredAccountId,
@@ -258,7 +368,11 @@ export function rankSubscriptionAccounts(
258
368
  effort: request.effort,
259
369
  now: request.now,
260
370
  maximumObservationAgeMs: request.maximumObservationAgeMs,
261
- mode: request.mode,
371
+ ordering: request.ordering,
372
+ ...(request.pinned !== undefined ? { pinned: request.pinned } : {}),
373
+ ...(request.unknownUtilization !== undefined
374
+ ? { unknownUtilization: request.unknownUtilization }
375
+ : {}),
262
376
  },
263
377
  accounts,
264
378
  );
@@ -267,7 +381,7 @@ export function rankSubscriptionAccounts(
267
381
  organizationId: route.organizationId,
268
382
  model: request.model,
269
383
  effort: request.effort,
270
- mode: request.mode ?? 'quartile-balanced',
384
+ ordering,
271
385
  candidates,
272
386
  };
273
387
  }