@orkestrel/agent 0.0.6 → 0.0.8

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/README.md CHANGED
@@ -14,7 +14,7 @@ npm install @orkestrel/agent
14
14
 
15
15
  ## Requirements
16
16
 
17
- - Node.js >= 24
17
+ - Node.js >= 22
18
18
  - Dual ESM + CommonJS builds (`import` and `require` both supported)
19
19
 
20
20
  ## Usage
@@ -74,6 +74,24 @@ var THINK_CLOSE = "</think>";
74
74
  */
75
75
  var WORKSPACE_SECTION_HEADER = "## Workspace";
76
76
  /**
77
+ * The estimated per-message role/framing overhead {@link import('./helpers.js').estimateMessages}
78
+ * adds on top of a message's content estimate — accounts for the fixed wire framing every
79
+ * conversation turn carries (its role tag, delimiters) that {@link import('./helpers.js').estimateTokens}'s
80
+ * content-only heuristic does not otherwise capture.
81
+ */
82
+ var MESSAGE_TOKEN_OVERHEAD = 4;
83
+ /**
84
+ * The coarse, deliberately-approximate per-image token cost {@link import('./helpers.js').estimateMessages}
85
+ * charges for each attached image.
86
+ *
87
+ * @remarks
88
+ * A base64 image payload's LENGTH is NOT a reliable token proxy (a vision model's actual image
89
+ * token cost depends on resolution / tiling, not byte size), so this is a fixed, coarse
90
+ * per-image estimate rather than a derivation from `image.length` — a planning heuristic, not an
91
+ * exact count.
92
+ */
93
+ var IMAGE_TOKEN_ESTIMATE = 512;
94
+ /**
77
95
  * The extension→language table {@link import('./helpers.js').inferLanguage} reads to
78
96
  * map a file path's extension to a fenced-code language tag. Covers the common source /
79
97
  * markup / data extensions; an unlisted extension falls back to `'text'`.
@@ -202,18 +220,21 @@ function isAgentJobError(value) {
202
220
  }
203
221
  /**
204
222
  * Thrown by a {@link ConversationInterface}'s `compact()` when the conversation has no
205
- * {@link ConversationSummarizer} to fold its messages with — carries a machine-readable
206
- * `code`.
223
+ * {@link ConversationSummarizer} to fold its messages with, or when its `sections` cap is
224
+ * structurally invalid — carries a machine-readable `code`.
207
225
  *
208
226
  * @remarks
209
227
  * Compaction REQUIRES a summarizer (it digests the folded slice into a section summary and
210
228
  * regenerates the rollup); a conversation created without one can still store + `view()` its
211
- * live tail, but a `compact()` is a programmer error (§12) and throws this. `code` is
212
- * `'SUMMARIZER'` (the only condition so far). Narrow a caught value with
229
+ * live tail, but a `compact()` is a programmer error (§12) and throws this with `'SUMMARIZER'`.
230
+ * A `sections` cap (§F2, on {@link import('./types.js').ConversationOptions} /
231
+ * {@link import('./types.js').ConversationManagerOptions} /
232
+ * {@link import('./types.js').CompactOptions}) must be `>= 1` — a sub-1 cap is a programmer
233
+ * error and throws this with `'SECTIONS'`. Narrow a caught value with
213
234
  * {@link isConversationError} and branch on `error.code`.
214
235
  */
215
236
  var ConversationError = class extends Error {
216
- /** The machine-readable condition — `'SUMMARIZER'`: a `compact()` with no summarizer. */
237
+ /** The machine-readable condition — `'SUMMARIZER'`: a `compact()` with no summarizer; `'SECTIONS'`: a sub-1 `sections` cap. */
217
238
  code;
218
239
  constructor(code, message) {
219
240
  super(message);
@@ -278,6 +299,45 @@ var WorkspaceError = class extends Error {
278
299
  function isWorkspaceError(value) {
279
300
  return value instanceof WorkspaceError;
280
301
  }
302
+ /**
303
+ * Thrown synchronously by an {@link AgentInterface}'s `stream()` when a concurrent run would
304
+ * corrupt SHARED per-agent accounting — carries a machine-readable `code`.
305
+ *
306
+ * @remarks
307
+ * A run already in flight on the same agent, PLUS a construction-level `window` (a shared
308
+ * context budget) or a construction-level `budget` with no per-run override (a shared cost
309
+ * budget), means a second concurrent `stream()` would race its charges against the same
310
+ * shared instance — corrupting the accounting. `code` is `'CONCURRENCY'` (the only condition
311
+ * so far). Use separate agents, or per-run `budget` overrides with no `window`, for genuinely
312
+ * concurrent runs. Narrow a caught value with {@link isAgentError} and branch on `error.code`.
313
+ */
314
+ var AgentError = class extends Error {
315
+ /** The machine-readable condition — `'CONCURRENCY'`: a concurrent run on a shared accounting agent. */
316
+ code;
317
+ constructor(code, message) {
318
+ super(message);
319
+ this.name = "AgentError";
320
+ this.code = code;
321
+ }
322
+ };
323
+ /**
324
+ * Narrow an unknown caught value to an {@link AgentError}.
325
+ *
326
+ * @param value - The value to test (typically a `catch` binding)
327
+ * @returns `true` when `value` is an {@link AgentError}
328
+ *
329
+ * @example
330
+ * ```ts
331
+ * try {
332
+ * agent.stream()
333
+ * } catch (error) {
334
+ * if (isAgentError(error) && error.code === 'CONCURRENCY') useSeparateAgents()
335
+ * }
336
+ * ```
337
+ */
338
+ function isAgentError(value) {
339
+ return value instanceof AgentError;
340
+ }
281
341
  //#endregion
282
342
  //#region src/core/conversations/Conversation.ts
283
343
  /**
@@ -331,6 +391,7 @@ var Conversation = class {
331
391
  #emitter;
332
392
  #summarize;
333
393
  #keep;
394
+ #cap;
334
395
  #sections = [];
335
396
  #summary;
336
397
  #messages = /* @__PURE__ */ new Map();
@@ -342,6 +403,8 @@ var Conversation = class {
342
403
  });
343
404
  this.#summarize = options?.summarize;
344
405
  this.#keep = options?.keep ?? 0;
406
+ if (options?.sections !== void 0 && options.sections < 1) throw new ConversationError("SECTIONS", "a sections cap must be >= 1");
407
+ this.#cap = options?.sections;
345
408
  if (seed) {
346
409
  this.#summary = seed.summary;
347
410
  for (const section of seed.sections) this.#sections.push(section);
@@ -393,6 +456,8 @@ var Conversation = class {
393
456
  async compact(options) {
394
457
  const summarize = this.#summarize;
395
458
  if (summarize === void 0) throw new ConversationError("SUMMARIZER", "cannot compact a conversation without a summarizer");
459
+ const cap = options?.sections ?? this.#cap;
460
+ if (cap !== void 0 && cap < 1) throw new ConversationError("SECTIONS", "a sections cap must be >= 1");
396
461
  const keep = options?.keep ?? this.#keep;
397
462
  const live = [...this.#messages.values()];
398
463
  const fold = keep <= 0 ? live.length : live.length - keep;
@@ -406,6 +471,23 @@ var Conversation = class {
406
471
  };
407
472
  for (const message of slice) this.#messages.delete(message.id);
408
473
  this.#sections.push(section);
474
+ if (cap !== void 0 && this.#sections.length > cap) {
475
+ const overflow = this.#sections.length - cap + 1;
476
+ const folded = this.#sections.slice(0, overflow);
477
+ try {
478
+ const merged = {
479
+ id: crypto.randomUUID(),
480
+ summary: await summarize(folded.map((one) => this.#summaryMessage(one))),
481
+ messages: folded.flatMap((one) => one.messages)
482
+ };
483
+ this.#sections.splice(0, overflow, merged);
484
+ this.#emitter.emit("collapse", merged);
485
+ } catch (error) {
486
+ this.#summary = await summarize(this.#sections.map((one) => this.#summaryMessage(one)));
487
+ this.#emitter.emit("summary", this.#summary);
488
+ throw error;
489
+ }
490
+ }
409
491
  this.#summary = await summarize(this.#sections.map((one) => this.#summaryMessage(one)));
410
492
  this.#emitter.emit("summary", this.#summary);
411
493
  this.#emitter.emit("compact", section);
@@ -505,10 +587,12 @@ var ConversationManager = class {
505
587
  #active;
506
588
  #summarize;
507
589
  #keep;
590
+ #sections;
508
591
  #store;
509
592
  constructor(options) {
510
593
  this.#summarize = options?.summarize;
511
594
  this.#keep = options?.keep ?? 0;
595
+ this.#sections = options?.sections;
512
596
  this.#store = options?.store;
513
597
  }
514
598
  get count() {
@@ -524,11 +608,13 @@ var ConversationManager = class {
524
608
  return [...this.#conversations.values()];
525
609
  }
526
610
  add(input) {
611
+ const sections = input?.sections ?? this.#sections;
527
612
  const conversation = new Conversation({
528
613
  ...input?.id === void 0 ? {} : { id: input.id },
529
614
  ...input?.on === void 0 ? {} : { on: input.on },
530
615
  summarize: input?.summarize ?? this.#summarize,
531
- keep: input?.keep ?? this.#keep
616
+ keep: input?.keep ?? this.#keep,
617
+ ...sections === void 0 ? {} : { sections }
532
618
  }, input?.snapshot);
533
619
  this.#conversations.set(conversation.id, conversation);
534
620
  if (this.#active === void 0) this.#active = conversation.id;
@@ -669,23 +755,40 @@ function estimateTokens(text) {
669
755
  * (the {@link import('./types.js').AgentOptions} `window`).
670
756
  *
671
757
  * @remarks
672
- * Sums {@link estimateTokens} over each message's `content` (the `ceil(length / 4)` char
673
- * heuristic), so it is deterministic and provider-free — the same messages always yield the
674
- * same estimate, with an empty batch `0`. It is the fully-swappable default an agent's
675
- * auto-compaction context budget charges each turn's new messages through; a caller wanting a
676
- * sharper count supplies its own `consume` to `createBudget` instead. Total — never throws.
758
+ * Sums, per message, {@link estimateTokens} over its `content` (the `ceil(length / 4)` char
759
+ * heuristic) PLUS {@link import('./constants.js').MESSAGE_TOKEN_OVERHEAD} (a fixed per-message
760
+ * role/framing overhead) PLUS, when present, {@link estimateTokens} over its JSON-stringified
761
+ * `calls` PLUS `images.length * `{@link import('./constants.js').IMAGE_TOKEN_ESTIMATE} (a coarse,
762
+ * deliberately-approximate per-image cost — a base64 length is NOT a token proxy). Deterministic
763
+ * and provider-free — the same messages always yield the same estimate, with an empty batch `0`.
764
+ * It is the fully-swappable default an agent's auto-compaction context budget charges each
765
+ * turn's new messages through; a caller wanting a sharper count supplies its own `consume` to
766
+ * `createBudget` instead. Total — never throws: a `calls` `JSON.stringify` that throws (a
767
+ * circular `ToolCall.arguments`) is caught and replaced with a conservative fixed contribution of
768
+ * {@link import('./constants.js').MESSAGE_TOKEN_OVERHEAD} (the same per-message overhead scale)
769
+ * instead of estimating the (unreachable) serialized length.
677
770
  *
678
771
  * @param messages - The messages to estimate (a turn's appended assistant + tool messages)
679
- * @returns The summed estimated token count (`Σ estimateTokens(m.content)`; `0` when empty)
772
+ * @returns The summed estimated token count (`0` when empty)
680
773
  *
681
774
  * @example
682
775
  * ```ts
683
776
  * estimateMessages([]) // 0
684
- * estimateMessages([{ id: '1', role: 'user', content: 'hello' }]) // 2
777
+ * estimateMessages([{ id: '1', role: 'user', content: 'hello' }]) // 6 (2 content + 4 overhead)
685
778
  * ```
686
779
  */
687
780
  function estimateMessages(messages) {
688
- return messages.reduce((sum, message) => sum + estimateTokens(message.content), 0);
781
+ return messages.reduce((sum, message) => {
782
+ const content = estimateTokens(message.content) + 4;
783
+ let calls = 0;
784
+ if (message.calls?.length) try {
785
+ calls = estimateTokens(JSON.stringify(message.calls));
786
+ } catch {
787
+ calls = 4;
788
+ }
789
+ const images = (message.images?.length ?? 0) * 512;
790
+ return sum + content + calls + images;
791
+ }, 0);
689
792
  }
690
793
  /**
691
794
  * Run one rehydrated agent and apply the partial-as-configurable-failure policy — the
@@ -1311,6 +1414,32 @@ function fencedFile(path, language, content) {
1311
1414
  function escapeRegExp(value) {
1312
1415
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1313
1416
  }
1417
+ /**
1418
+ * Sanitize a {@link TokenUsage} into safe, non-negative integers — the guard an agent's
1419
+ * abort-usage path applies to a provider's partial usage before it is charged against a
1420
+ * budget or folded into the run total.
1421
+ *
1422
+ * @remarks
1423
+ * Per field (`prompt` / `completion` / `total`): a non-finite value (`NaN`, `+Infinity`,
1424
+ * `-Infinity`) or a negative value floors to `0`; a fractional value floors to its
1425
+ * non-negative integer part. No upper cap is applied. Total — never throws.
1426
+ *
1427
+ * @param usage - The token usage to sanitize (e.g. a provider's abort-partial usage)
1428
+ * @returns A new {@link TokenUsage} with every field a safe non-negative integer
1429
+ *
1430
+ * @example
1431
+ * ```ts
1432
+ * sanitizeUsage({ prompt: -5, completion: NaN, total: 12.7 }) // { prompt: 0, completion: 0, total: 12 }
1433
+ * ```
1434
+ */
1435
+ function sanitizeUsage(usage) {
1436
+ const clean = (value) => Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
1437
+ return {
1438
+ prompt: clean(usage.prompt),
1439
+ completion: clean(usage.completion),
1440
+ total: clean(usage.total)
1441
+ };
1442
+ }
1314
1443
  //#endregion
1315
1444
  //#region src/core/instructions/Instruction.ts
1316
1445
  /**
@@ -2236,6 +2365,7 @@ var Agent = class {
2236
2365
  #signal;
2237
2366
  #authority;
2238
2367
  #window;
2368
+ #strict;
2239
2369
  #emitter;
2240
2370
  id = crypto.randomUUID();
2241
2371
  #status = "idle";
@@ -2245,6 +2375,9 @@ var Agent = class {
2245
2375
  this.#context = new AgentContext({
2246
2376
  system: options?.system,
2247
2377
  tools: options?.tools,
2378
+ instructions: options?.instructions,
2379
+ workspaces: options?.workspaces,
2380
+ scope: options?.scope,
2248
2381
  conversations: options?.conversations
2249
2382
  });
2250
2383
  this.#limit = options?.limit ?? 10;
@@ -2254,6 +2387,7 @@ var Agent = class {
2254
2387
  this.#signal = options?.signal;
2255
2388
  this.#authority = options?.authority;
2256
2389
  this.#window = options?.window;
2390
+ this.#strict = options?.strict ?? false;
2257
2391
  this.#emitter = new _orkestrel_emitter.Emitter({
2258
2392
  on: options?.on,
2259
2393
  error: options?.error
@@ -2272,6 +2406,7 @@ var Agent = class {
2272
2406
  return this.stream(options).result;
2273
2407
  }
2274
2408
  stream(options) {
2409
+ if (this.#runs.size > 0 && (this.#window !== void 0 || this.#budget !== void 0 && options?.budget === void 0)) throw new AgentError("CONCURRENCY", "concurrent runs on one agent with a shared construction window/budget corrupt accounting; use separate agents or per-run budgets");
2275
2410
  const timeoutMs = options?.timeout ?? this.#timeoutMs;
2276
2411
  const timeout = timeoutMs === void 0 ? void 0 : (0, _orkestrel_timeout.createTimeout)({ ms: timeoutMs });
2277
2412
  timeout?.start();
@@ -2391,7 +2526,7 @@ var Agent = class {
2391
2526
  if (isProviderAbortError(error)) {
2392
2527
  if (error.partial.thinking !== void 0) thinking = this.#thought(thinking, error.partial.thinking);
2393
2528
  if (error.partial.usage !== void 0) {
2394
- const abortUsage = error.partial.usage;
2529
+ const abortUsage = sanitizeUsage(error.partial.usage);
2395
2530
  budget?.consume({
2396
2531
  prompt: abortUsage.prompt,
2397
2532
  completion: Math.max(0, abortUsage.completion - charged),
@@ -2408,16 +2543,17 @@ var Agent = class {
2408
2543
  }
2409
2544
  if (result.thinking !== void 0 && result.thinking.length > 0) thinking = this.#thought(thinking, result.thinking);
2410
2545
  if (result.usage !== void 0) {
2546
+ const resultUsage = sanitizeUsage(result.usage);
2411
2547
  budget?.consume({
2412
- prompt: result.usage.prompt,
2413
- completion: Math.max(0, result.usage.completion - charged),
2414
- total: Math.max(0, result.usage.total - charged)
2548
+ prompt: resultUsage.prompt,
2549
+ completion: Math.max(0, resultUsage.completion - charged),
2550
+ total: Math.max(0, resultUsage.total - charged)
2415
2551
  });
2416
- usage = this.#sum(usage, result.usage);
2417
- this.#emitter.emit("usage", result.usage);
2552
+ usage = this.#sum(usage, resultUsage);
2553
+ this.#emitter.emit("usage", resultUsage);
2418
2554
  yield {
2419
2555
  type: "usage",
2420
- usage: result.usage
2556
+ usage: resultUsage
2421
2557
  };
2422
2558
  }
2423
2559
  if (result.tools !== void 0 && result.tools.length > 0) {
@@ -2476,6 +2612,7 @@ var Agent = class {
2476
2612
  section = await conversation.compact();
2477
2613
  } catch (error) {
2478
2614
  this.#emitter.emit("compactError", error);
2615
+ if (this.#strict) throw error;
2479
2616
  return;
2480
2617
  }
2481
2618
  if (section === void 0) {
@@ -4064,6 +4201,7 @@ function createWorkspaceManager(options) {
4064
4201
  //#endregion
4065
4202
  exports.Agent = Agent;
4066
4203
  exports.AgentContext = AgentContext;
4204
+ exports.AgentError = AgentError;
4067
4205
  exports.AgentJobError = AgentJobError;
4068
4206
  exports.AgentRegistry = AgentRegistry;
4069
4207
  exports.Authority = Authority;
@@ -4078,8 +4216,10 @@ exports.DEFAULT_CONVERSATION_KEEP = DEFAULT_CONVERSATION_KEEP;
4078
4216
  exports.DatabaseConversationStore = DatabaseConversationStore;
4079
4217
  exports.DatabaseWorkspaceStore = DatabaseWorkspaceStore;
4080
4218
  exports.EXTENSION_TO_LANGUAGE = EXTENSION_TO_LANGUAGE;
4219
+ exports.IMAGE_TOKEN_ESTIMATE = IMAGE_TOKEN_ESTIMATE;
4081
4220
  exports.Instruction = Instruction;
4082
4221
  exports.InstructionManager = InstructionManager;
4222
+ exports.MESSAGE_TOKEN_OVERHEAD = MESSAGE_TOKEN_OVERHEAD;
4083
4223
  exports.MemoryConversationStore = MemoryConversationStore;
4084
4224
  exports.MemoryWorkspaceStore = MemoryWorkspaceStore;
4085
4225
  exports.ProviderAbortError = ProviderAbortError;
@@ -4130,6 +4270,7 @@ exports.estimateTokens = estimateTokens;
4130
4270
  exports.fencedFile = fencedFile;
4131
4271
  exports.filterAllowList = filterAllowList;
4132
4272
  exports.inferLanguage = inferLanguage;
4273
+ exports.isAgentError = isAgentError;
4133
4274
  exports.isAgentJobError = isAgentJobError;
4134
4275
  exports.isBinary = isBinary;
4135
4276
  exports.isConversationError = isConversationError;
@@ -4146,6 +4287,7 @@ exports.isWorkspaceError = isWorkspaceError;
4146
4287
  exports.isWorkspaceSnapshot = isWorkspaceSnapshot;
4147
4288
  exports.offsetAt = offsetAt;
4148
4289
  exports.rangeOf = rangeOf;
4290
+ exports.sanitizeUsage = sanitizeUsage;
4149
4291
  exports.settleAgentJob = settleAgentJob;
4150
4292
  exports.sliceRange = sliceRange;
4151
4293
  exports.spliceRange = spliceRange;