@rarebit-one/harness-kernel 0.18.0 → 0.19.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.
Files changed (99) hide show
  1. package/README.md +255 -16
  2. package/dist/agent.d.ts +24 -0
  3. package/dist/agent.d.ts.map +1 -1
  4. package/dist/agent.js +138 -30
  5. package/dist/agent.js.map +1 -1
  6. package/dist/connectors/auth.d.ts +32 -0
  7. package/dist/connectors/auth.d.ts.map +1 -0
  8. package/dist/connectors/auth.js +85 -0
  9. package/dist/connectors/auth.js.map +1 -0
  10. package/dist/connectors/mcpClient.d.ts +14 -4
  11. package/dist/connectors/mcpClient.d.ts.map +1 -1
  12. package/dist/connectors/mcpClient.js +32 -11
  13. package/dist/connectors/mcpClient.js.map +1 -1
  14. package/dist/engines/acp.d.ts.map +1 -1
  15. package/dist/engines/acp.js +2 -1
  16. package/dist/engines/acp.js.map +1 -1
  17. package/dist/engines/capability.d.ts +1 -1
  18. package/dist/engines/capability.js +1 -1
  19. package/dist/engines/claudeCode.d.ts.map +1 -1
  20. package/dist/engines/claudeCode.js +2 -1
  21. package/dist/engines/claudeCode.js.map +1 -1
  22. package/dist/engines/claudeCodeDriver.d.ts.map +1 -1
  23. package/dist/engines/claudeCodeDriver.js +4 -3
  24. package/dist/engines/claudeCodeDriver.js.map +1 -1
  25. package/dist/engines/codex.d.ts +4 -4
  26. package/dist/engines/codex.d.ts.map +1 -1
  27. package/dist/engines/codex.js +6 -5
  28. package/dist/engines/codex.js.map +1 -1
  29. package/dist/engines/codexDriver.d.ts +1 -1
  30. package/dist/engines/codexDriver.js +1 -1
  31. package/dist/engines/connectorMcp.d.ts +13 -0
  32. package/dist/engines/connectorMcp.d.ts.map +1 -1
  33. package/dist/engines/connectorMcp.js +23 -0
  34. package/dist/engines/connectorMcp.js.map +1 -1
  35. package/dist/engines/gateway.d.ts +5 -2
  36. package/dist/engines/gateway.d.ts.map +1 -1
  37. package/dist/engines/gateway.js +7 -2
  38. package/dist/engines/gateway.js.map +1 -1
  39. package/dist/engines/native.d.ts.map +1 -1
  40. package/dist/engines/native.js +10 -3
  41. package/dist/engines/native.js.map +1 -1
  42. package/dist/engines/types.d.ts +10 -1
  43. package/dist/engines/types.d.ts.map +1 -1
  44. package/dist/events.d.ts +22 -8
  45. package/dist/events.d.ts.map +1 -1
  46. package/dist/events.js.map +1 -1
  47. package/dist/index.d.ts +10 -2
  48. package/dist/index.d.ts.map +1 -1
  49. package/dist/index.js +5 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/loop.d.ts +23 -0
  52. package/dist/loop.d.ts.map +1 -1
  53. package/dist/loop.js +1 -0
  54. package/dist/loop.js.map +1 -1
  55. package/dist/models/chat.d.ts +12 -6
  56. package/dist/models/chat.d.ts.map +1 -1
  57. package/dist/models/chat.js +11 -6
  58. package/dist/models/chat.js.map +1 -1
  59. package/dist/models/retry.d.ts +252 -0
  60. package/dist/models/retry.d.ts.map +1 -0
  61. package/dist/models/retry.js +400 -0
  62. package/dist/models/retry.js.map +1 -0
  63. package/dist/models/types.d.ts +7 -0
  64. package/dist/models/types.d.ts.map +1 -1
  65. package/dist/models/types.js.map +1 -1
  66. package/dist/private.d.ts.map +1 -1
  67. package/dist/private.js +12 -2
  68. package/dist/private.js.map +1 -1
  69. package/dist/secrets.d.ts +17 -7
  70. package/dist/secrets.d.ts.map +1 -1
  71. package/dist/secrets.js +38 -24
  72. package/dist/secrets.js.map +1 -1
  73. package/dist/tools/concurrency.d.ts +48 -0
  74. package/dist/tools/concurrency.d.ts.map +1 -0
  75. package/dist/tools/concurrency.js +64 -0
  76. package/dist/tools/concurrency.js.map +1 -0
  77. package/dist/tools/metadata.d.ts +18 -3
  78. package/dist/tools/metadata.d.ts.map +1 -1
  79. package/dist/tools/metadata.js +7 -0
  80. package/dist/tools/metadata.js.map +1 -1
  81. package/dist/tools/registry.d.ts +6 -4
  82. package/dist/tools/registry.d.ts.map +1 -1
  83. package/dist/tools/registry.js +5 -3
  84. package/dist/tools/registry.js.map +1 -1
  85. package/dist/tools/terminal.d.ts +8 -6
  86. package/dist/tools/terminal.d.ts.map +1 -1
  87. package/dist/tools/terminal.js +8 -6
  88. package/dist/tools/terminal.js.map +1 -1
  89. package/dist/tools/validate.d.ts +49 -0
  90. package/dist/tools/validate.d.ts.map +1 -0
  91. package/dist/tools/validate.js +346 -0
  92. package/dist/tools/validate.js.map +1 -0
  93. package/dist/transcript.d.ts +53 -0
  94. package/dist/transcript.d.ts.map +1 -0
  95. package/dist/transcript.js +104 -0
  96. package/dist/transcript.js.map +1 -0
  97. package/dist/types.d.ts +44 -5
  98. package/dist/types.d.ts.map +1 -1
  99. package/package.json +3 -3
package/README.md CHANGED
@@ -59,7 +59,7 @@ Every other engine and the whole native loop work without it. Running the
59
59
  |------|---------|
60
60
  | **Model seam** | `ModelInvocation` — one arrow for every model kind, with declared capabilities, a uniform result envelope and `probe()` |
61
61
  | **Providers** | `Provider` interface + `selectProvider()` over Anthropic, OpenAI, OpenRouter, a self-hosted `local` (any OpenAI-compatible server), an offline `mock` (by request) and a `ScriptedProvider` for recorded evals; `chatModel()` puts any of them on the seam; typed per-call `ProviderOptions` (prompt caching, token cap, response format, OpenRouter routing) |
62
- | **Agent loop** | `runConversation()` — a provider-neutral tool-use loop over a seeded conversation, with step and wall-clock budgets, terminal (final-answer) tools, summed usage and the served model in the result; `runAgent()` is its one-prompt form |
62
+ | **Agent loop** | `runConversation()` — a provider-neutral tool-use loop over a seeded conversation, with step, wall-clock and optional cost budgets, terminal (final-answer) tools, summed usage and the served model in the result; `runAgent()` is its one-prompt form |
63
63
  | **Loop** | `Loop` + `nativeLoop` — the control loop as a seam; the kernel ships one and it is the loop `runAgent` and `runConversation` have always run |
64
64
  | **Run events** | `RunEvent` — an ordered, structured account of a run (turns, tool calls, budgets, outcome), emitted to an optional sink |
65
65
  | **Tools** | `Tool` (+ `ToolContext`), `primitiveTools()` (`run_code`, `read_file`, `list_files`, `http_fetch`), `connectorTools()`, `modelAsTool()`, metadata + projections (`replaySafeTools`, …) |
@@ -251,7 +251,7 @@ Every entry point takes an optional `AbortSignal` — `runAgent({ signal })`,
251
251
  const controller = new AbortController()
252
252
  const result = await engine.run(spec, { log, signal: controller.signal })
253
253
  // later: controller.abort("user canceled")
254
- result.outcome // "completed" | "steps_exhausted" | "timed_out" | "canceled" | "failed"
254
+ result.outcome // "completed" | "steps_exhausted" | "timed_out" | "cost_exhausted" | "canceled" | "failed" | "suspended"
255
255
  ```
256
256
 
257
257
  The native loop merges it with its own wall-clock deadline and threads the
@@ -358,7 +358,7 @@ const result = await runConversation({
358
358
  providerOptions: { promptCache: {} },
359
359
  })
360
360
 
361
- result.stopReason // "end_turn" | "terminal_tool" | "steps_exhausted" | "timed_out" | "canceled" | "suspended"
361
+ result.stopReason // "end_turn" | "terminal_tool" | "length" | "steps_exhausted" | "timed_out" | "cost_exhausted" | "canceled" | "suspended"
362
362
  result.terminal?.input // { text, quick_replies } when the model called `reply`
363
363
  result.messages // store this; seed the next turn with it
364
364
  result.pending // the external calls to execute when outcome is "suspended"
@@ -373,8 +373,39 @@ so the transcript carries no dangling call, and then ends the run with
373
373
  `stopReason: "terminal_tool"` and the call on `result.terminal`. An executor
374
374
  that throws turns the call into an ordinary tool error the model gets to
375
375
  correct. `terminalTool()` builds one with a no-op executor; the structured
376
- answer is the call's `input`, which the kernel does not validate against the
377
- schema (run your own validator on it, or supply an executor that throws).
376
+ answer is the call's `input`. By default the kernel does not check it against
377
+ the schema; see **Argument validation** below, or supply an executor that
378
+ throws for rules a schema cannot express.
379
+
380
+ **Argument validation.** `validateToolInput: true` (off by default; also on
381
+ `RunSpec` for the native engine) checks every call's arguments against its
382
+ tool's `inputSchema` before the call runs or parks. A call that does not fit
383
+ is not run: the model gets an error result naming each failing argument by
384
+ JSON Pointer (`invalid arguments for reply; the call was not run. … /text: is
385
+ required`), the stream records `tool.failed` with reason `invalid_input`, and
386
+ a terminal call refused this way does not end the run. This is a refusal, the
387
+ same family as the cut-off refusal below, not a retry: the loop makes no extra
388
+ model call, and the model corrects the call within its own step budget. The
389
+ check covers the JSON Schema that tool schemas use (`type`, `properties`,
390
+ `required`, `additionalProperties`, `enum`, `const`, `items`, the length, size
391
+ and range bounds, `pattern`, `allOf`/`anyOf`/`oneOf`, local `$ref`s) and
392
+ ignores every other keyword (`format`, `not`, `if`/`then`/`else`, …), so it can
393
+ miss a bad call but never refuses one the schema accepts. That is why it also
394
+ applies to MCP connector tools, whose schemas the server supplies: a server
395
+ keyword the check does not know is skipped, not guessed at, and a bad call is
396
+ caught before the round trip. OpenAPI's `nullable` is honoured, and a schema
397
+ declaring draft 03–07 has its `$ref` siblings ignored as those drafts did, and
398
+ a keyword its declared draft did not have (`const` before draft 6,
399
+ `prefixItems` before 2020-12) is ignored too; a
400
+ schema the check cannot read lets the call through rather than failing the
401
+ run, and so does a local `$ref` inside an embedded resource (a subschema with
402
+ its own `$id`) or a schema whose check would take more than a fixed amount of
403
+ work (shared definitions reached from many composition branches). One caution: `pattern` runs synchronously on the model's input, so a
404
+ pattern with catastrophic backtracking can stall the run past its deadline;
405
+ turn validation on only for tools whose schemas you would run that way.
406
+ `validateToolInput(schema, input)` is exported
407
+ for a caller that wants the same check elsewhere (on `terminal.input`, or in
408
+ its own loop); it returns `{ path, message }[]`, empty when the input fits.
378
409
 
379
410
  The seed must end with a user message or with the tool results the model is
380
411
  waiting for; the loop copies it and never mutates the array it was handed.
@@ -417,7 +448,7 @@ if (parked.outcome === "suspended") {
417
448
  system,
418
449
  messages: withToolResults(parked.messages, results),
419
450
  tools: [...tools, askHuman], // keep it: the model may ask again
420
- ...remainingLimits({ maxSteps, maxDurationMs }, parked.consumed),
451
+ ...remainingLimits({ maxSteps, maxDurationMs, maxCostUsd }, parked.consumed),
421
452
  })
422
453
  }
423
454
  ```
@@ -430,16 +461,106 @@ closes each parked call in its own event stream, at step 0, as `tool.succeeded`
430
461
  or `tool.failed` with `reason: "rejected"`, so a consumer following the stream
431
462
  across both runs sees every `tool.pending` matched. `remainingLimits` carries
432
463
  the budget over, floored at one step and one minute so the first resumed step
433
- always runs; a resumed run is a continuation, not a fresh budget. A terminal
464
+ always runs (a cost budget carries over as the limit minus the cost the parked
465
+ run reported); a resumed run is a continuation, not a fresh budget. A terminal
434
466
  answer or a stop in the same step ends the run instead, and the parked calls
435
467
  are answered as not run so the transcript stays whole.
436
468
 
469
+ **Settling a stored transcript.** A transcript the kernel returns from a
470
+ finished, stopped or cut-off run is already whole, but a `suspended` one ends on
471
+ the parked calls, and a transcript an application built, cut or appended to
472
+ itself may leave calls unanswered anywhere. Every provider rejects a tool call
473
+ with no result. `settleMessages` answers each missing one (into the tool turn
474
+ that follows its assistant turn, else as a new tool turn after it) and leaves
475
+ answered calls alone, so settling twice changes nothing. `withUserTurn` settles
476
+ and appends the next user message, and its output always passes the loop's seed
477
+ check:
478
+
479
+ ```ts
480
+ import { settleMessages, withToolResults, withUserTurn } from "@rarebit-one/harness-kernel"
481
+
482
+ // The user moved on instead of answering what the last run parked:
483
+ // the parked calls are closed as not run, then the new message follows.
484
+ const next = withUserTurn(stored, incoming)
485
+
486
+ // Or say why, per call:
487
+ const declined = withUserTurn(stored, incoming, {
488
+ answer: (call) => ({ content: `${call.name} was abandoned`, isError: true, rejected: true }),
489
+ })
490
+
491
+ // A resume where the caller answered only some parked calls; settle closes the rest.
492
+ const resume = settleMessages(withToolResults(parked.messages, results))
493
+ ```
494
+
495
+ Without `answer`, a missing result is `{ content: "not run: the turn ended
496
+ before this tool call was answered", isError: true }`. Settle after
497
+ `withToolResults`, never before: the other way round gives a parked call its
498
+ placeholder and then its real result, and providers refuse two results for one
499
+ call. What the loop never writes throws rather than being dropped: a tool turn
500
+ that answers no call (no assistant turn before it, or a result for a call id
501
+ that turn never made), two results for one call, two calls with one id, and one
502
+ turn's results split across consecutive tool turns. A tool turn settle completes
503
+ lists its results in call order. Both helpers are pure: no I/O, nothing stored, and neither array is
504
+ mutated. They do not window or trim history, and they carry no budget: pass
505
+ fresh limits for a per-turn budget, or `remainingLimits(limits, consumed)` to
506
+ spend one budget across turns.
507
+
437
508
  **Budget visibility.** `budgetVisible: true` (off by default) appends one
438
509
  ephemeral user line to every request, `[budget] N of M step(s) and Ss remain`,
439
- counting down. It is never written into `messages`, and it rides after the
510
+ counting down (with `, Ss and $X of cost` in place of `and Ss` under a cost
511
+ budget). It is never written into `messages`, and it rides after the
440
512
  history so the cached prefix (system, tools, earlier turns) is untouched. The
441
513
  limits stay hard either way; the line only lets the model pace itself.
442
514
 
515
+ ### Parallel tool calls
516
+
517
+ By default the loop runs a step's tool calls one at a time, in the order the
518
+ model issued them. A run can opt into running some of them together:
519
+
520
+ ```ts
521
+ import { isConcurrencySafe, runConversation, type Tool } from "@rarebit-one/harness-kernel"
522
+
523
+ const readFile: Tool = { spec, meta: { concurrencySafe: true }, execute }
524
+
525
+ await runConversation({
526
+ provider,
527
+ system,
528
+ messages,
529
+ tools,
530
+ toolConcurrency: { maxConcurrent: 8, concurrencySafe: isConcurrencySafe },
531
+ })
532
+ ```
533
+
534
+ `meta.concurrencySafe: true` is how a tool says it has no effect another call
535
+ could observe or race. The loop never reads it: the run passes the predicate,
536
+ and `isConcurrencySafe` is the one that honours the tools' own claims. An
537
+ application with its own rule (by name, by connector) passes that instead.
538
+
539
+ The ordering rule keeps the model's order wherever it can matter. A step's
540
+ calls are split, in order, into segments: each run of consecutive safe calls is
541
+ one segment and runs in parallel, at most `maxConcurrent` at once; every other
542
+ call is a segment of its own, and a segment starts only when the one before it
543
+ has settled (a cap above a step's number of calls simply runs them all). So
544
+ `[read, read, write, read]` runs both reads together, then the write alone,
545
+ then the last read. A call nobody vouched for sees every earlier call finished
546
+ and no later call started, exactly as in series.
547
+
548
+ Everything else holds as in series. Results go back to the model in call order,
549
+ whatever order they finished in. `tool.called` is emitted, in call order, as
550
+ each call starts and before its effect; `tool.succeeded`/`tool.failed` arrive
551
+ as each one finishes, and `seq` stays gapless. A cancel or the deadline aborts
552
+ the calls in flight, and calls not yet started are answered as not run. If the
553
+ loop itself fails partway through a segment (a `log` callback that throws,
554
+ say), no further call starts, and the failure propagates only once the calls in
555
+ flight have settled, so no event follows `run.finished`. When several terminal
556
+ calls succeed, the answer is the first one the model issued, not the first to
557
+ finish. External calls are parked as they come up and the run suspends once the
558
+ step's other calls have settled.
559
+
560
+ `NativeEngine` takes its run from a `RunSpec`, which is data, so it does not
561
+ carry the predicate; give the engine a loop that adds it:
562
+ `{ name: "native", run: (req, ctx) => nativeLoop.run({ ...req, toolConcurrency }, ctx) }`.
563
+
443
564
  ### Provider options and prompt caching
444
565
 
445
566
  `providerOptions` rides on every model call a run makes and each adapter maps it
@@ -498,8 +619,26 @@ Usage carries what the provider prices: beside the token counts, `TokenUsage`
498
619
  has `cacheWriteTokens`, `reasoningTokens` and `reportedCost`
499
620
  (`{ amount, currency: "USD" }`, from OpenRouter's `usage.cost`) when the
500
621
  response reports them, on every `model.turn` event and summed by amount on the
501
- result, so a per-turn cost cap can abort the run through its `signal`. Absent
502
- means unknown, never free.
622
+ result. Absent means unknown, never free.
623
+
624
+ **Cost budget.** `maxCostUsd` (on `runConversation`/`runAgent`, `RunSpec.limits`
625
+ for the native engine, `LoopLimits` for a loop) is a third budget beside steps
626
+ and time, with no default: absent means unlimited. After each model turn the
627
+ loop compares the run's summed `reportedCost` with it. Once it is reached, a
628
+ turn that asked for tools ends the run before any of them start, with
629
+ `outcome: "cost_exhausted"`, a `run.budget_exhausted` event of `kind: "cost"`,
630
+ the accumulated prose plus a note, and every call of that turn answered as not
631
+ run so the transcript resumes. No summary call is made, because it would spend
632
+ past a limit already reached; a turn that answered in prose completes as usual.
633
+ The turn that crosses the line is the overshoot, since its cost is known only
634
+ after it returns. Only reported cost counts: a turn that reports none adds
635
+ nothing (logged once per run), so under a provider that does not price its
636
+ responses the limit sees only part of the spend, or none of it. Only the run's own
637
+ model turns count, too: a tool that spends (a `modelAsTool` sub-model, a paid
638
+ API) does so outside the limit, because its usage never reaches the run's. The kernel carries no price
639
+ table; to bring unpriced calls inside the limit, fill `usage.reportedCost` in a
640
+ model `Middleware` from your own prices. Conversation-level or daily caps span
641
+ runs and stay with the application. The out-of-process engines ignore it.
503
642
 
504
643
  Two more per-call knobs: `maxRetries` overrides the SDK retry count for one
505
644
  call (set `0` alongside an OpenRouter `models` chain, or the SDK and the router
@@ -508,6 +647,101 @@ both retry), and the `openai` adapter sends the cap as `max_completion_tokens`
508
647
  local servers get `max_tokens`. `connectorTools(..., { toolName })` names MCP
509
648
  tools your way when the default `<connector>__<tool>` prefix is in the way.
510
649
 
650
+ A connector whose credential rotates during a run sets `auth.headers`, a
651
+ function (async allowed) returning the headers for one request, instead of a
652
+ static `auth.token`. The HTTP transports ask it before every request, and after
653
+ a 401 ask once more with `refresh: true` and retry that request once; a throw
654
+ fails the request with `connector <name>: auth headers function failed`, never
655
+ the function's own message. The native engine masks every value it returns in
656
+ tool output, as it does the run's `secrets` and a static token, so return only
657
+ credential headers. These requests never follow a cross-origin redirect. The
658
+ out-of-process engines read their server config once, so they refuse such a run
659
+ in `supports()`, and stdio refuses it (its token is set at spawn).
660
+ `ConnectorConfig.clientInfo` (`{ name, version? }`) replaces the default client
661
+ identity announced to the server.
662
+
663
+ ### Retry and fallback
664
+
665
+ The kernel retries nothing by itself beyond what the vendor SDKs do
666
+ (`maxRetries`, default 2). A caller that needs more — a wait that knows the
667
+ run's deadline, an honoured `Retry-After`, other models to try once the primary
668
+ is exhausted — wraps its model in `withRetry` and turns the SDK's own retries
669
+ off, so the two do not multiply:
670
+
671
+ ```ts
672
+ import { chatModel, withRetry, OpenRouterProvider } from "@rarebit-one/harness-kernel"
673
+
674
+ const primary = chatModel(new OpenRouterProvider(key, "vendor-a/large"))
675
+ const backup = chatModel(new OpenRouterProvider(key, "vendor-b/medium"))
676
+
677
+ const model = withRetry(primary, {
678
+ maxAttempts: 4, // per model, first call included
679
+ backoff: (n) => [1000, 2000, 3000][n - 1] ?? 3000, // or { baseMs, maxMs, jitter }
680
+ waitBudgetMs: 6000, // total wait across this wrapper's calls
681
+ deadlineAt: turnStartedAt + 45_000, // optional inside the loop, which passes the run's deadline
682
+ minCallMs: 10_000, // never leave the next call under 10 s
683
+ fallbacks: [backup], // explicit, ordered; none by default
684
+ stickyFallback: true, // once the primary is exhausted, skip it
685
+ onEvent: (e) => log(JSON.stringify(e)), // each retry and fallback as it happens
686
+ })
687
+
688
+ await runConversation({ provider: model, providerOptions: { maxRetries: 0 }, ... })
689
+ ```
690
+
691
+ Each failure is classified `retry` (try the same model again after a backoff,
692
+ then move on), `fallback` (move to the next model now) or `fatal` (rethrow it
693
+ unchanged). `defaultRetryClassifier` retries rate limits, timeouts, 5xx and
694
+ dropped connections, and treats everything else as fatal: auth, payment, an
695
+ invalid request, a refused credential or option, and any error it does not
696
+ recognise. Pass `classify` to decide differently, for example to send a 5xx
697
+ straight to the fallback. A server's `Retry-After` (or `retry-after-ms`)
698
+ replaces the computed wait; one longer than the budget left is not shortened,
699
+ since a shortened wait would only be refused again, so the chain moves on.
700
+ A server wait above `maxRetryAfterMs` (default 60 s, as the SDKs do) is
701
+ treated the same way. Wrappers compose: a `withRetry` used as one entry of
702
+ another's chain that runs out (`RetryExhaustedError`) moves the outer chain to
703
+ its next model, so each model can carry its own retry policy.
704
+
705
+ The deadline is the earliest of `deadlineAt`, the run's deadline
706
+ (`InvokeContext.budget.deadlineAt`, which the native loop sets on every call,
707
+ so a late step knows how little of the run is left) and the call's
708
+ `budget.maxDurationMs`. Outside the native loop, pass `deadlineAt` yourself.
709
+ Each model in the chain is called with that deadline as `budget.deadlineAt`,
710
+ so a nested `withRetry` never waits past it either.
711
+
712
+ `waitBudgetMs` and `stickyFallback` are state of the wrapper instance and never
713
+ reset: when you set either, **build one wrapper per run or turn**. A wrapper
714
+ registered once at startup and shared by every run would spend its wait budget
715
+ on the first rate limit and never retry again, or never return to its primary.
716
+ A wrapper with neither option keeps no state between calls and is safe to
717
+ share.
718
+
719
+ On success `ModelResult.meta.retry` says what happened: which model `served`,
720
+ its `position` in the chain, how many `attempts` were made, which models it
721
+ `fellBackFrom` and how long it `waitedMs` (two adapters of one provider share
722
+ an id, which is what `position` is for). A chain entry that is itself a
723
+ `withRetry` wrapper folds its own report in, so `served` names the model inside
724
+ it that answered. The chat result's own `model`, `responseId` and `upstream`
725
+ come from the model that answered, so `model.turn` events audit a fallback the
726
+ same way they audit any turn. When the chain runs out, `RetryExhaustedError`
727
+ carries every failure, the last as its `cause`, and `waitedMs`; an outer wrapper
728
+ counts a nested one that ran out call by call. An abort stops a wait at once
729
+ and is rethrown as the signal's reason, so the loop still ends `canceled` or
730
+ `timed_out`, never `failed`.
731
+
732
+ There is never an implicit fallback: an empty `fallbacks` means retry only.
733
+ A streaming call is never retried once its sink has received output. And
734
+ `private: true` (set it for a `RunSpec.private` run) refuses a fallback chain
735
+ when the wrapper is built, including a primary that is itself a `withRetry`
736
+ wrapper with fallbacks (a nested wrapper must be built `private: true` too),
737
+ because a fallback is exactly how a private run's
738
+ material would reach another model; retrying its one model is still allowed.
739
+ No upstream error text leaves a private wrapper by its own hand: `onEvent`
740
+ events carry no `error`, log lines name the status at most, and
741
+ `RetryExhaustedError`'s `failures` and `cause` are narrowed to the status. A
742
+ fatal error is still rethrown as the model threw it.
743
+ The wrapper is payload-generic, so it fits any model kind, not only chat.
744
+
511
745
  ### Recorded evals with `ScriptedProvider`
512
746
 
513
747
  ```ts
@@ -540,11 +774,15 @@ would defeat the point.
540
774
  ### Engines
541
775
 
542
776
  An engine is a pluggable agent harness. `native` is the in-process tool-use loop
543
- above; `claude-code` and `codex` shell out to those CLIs/SDKs. All three share
544
- one capability surface (`open_issue`, `write_file`, `promote_knowledge`) scoped
545
- to a single run's workspace, and all three mutate files **out of band** — the
546
- caller diffs the working tree afterwards, so the engine contract never has to
547
- model "an edit" uniformly.
777
+ above; `claude-code` and `codex` shell out to those CLIs/SDKs. The kernel ships
778
+ one capability, `write_file` (`writeFileCapability`), scoped to a single run's
779
+ workspace, and `claude-code` mounts it by default. Every other capability is the
780
+ application's, injected per engine: `domainTools` for `native`,
781
+ `capabilityTools` for `claude-code`, a spawnable `capabilityServerScript` for
782
+ `codex` (see
783
+ [The kernel defines no domain tools](#the-kernel-defines-no-domain-tools)). All
784
+ three mutate files **out of band** — the caller diffs the working tree
785
+ afterwards, so the engine contract never has to model "an edit" uniformly.
548
786
 
549
787
  ```ts
550
788
  import { selectEngine } from "@rarebit-one/harness-kernel"
@@ -696,7 +934,8 @@ checks.
696
934
  A `vm` run is also refused when it holds **exposed** credentials, which no
697
935
  gateway can inject and which therefore reach the guest in the clear: non-empty
698
936
  `secrets`, or a connector credential the harness carries itself (`auth.token`,
699
- or `headers` / `env` values on a caller's connector shape). A connector whose
937
+ an `auth.headers` function, or `headers` / `env` values on a caller's connector
938
+ shape). A connector whose
700
939
  token is the gateway's run token is routed, not exposed. The caller's policy can
701
940
  lift this with `RunSpec.exposedCredentials: "allowed"`; absent, it refuses.
702
941
  Below `vm` nothing changes.
package/dist/agent.d.ts CHANGED
@@ -5,6 +5,7 @@ import type { TokenUsage } from "./models/types.js";
5
5
  import type { ProviderOptions } from "./providers/options.js";
6
6
  import type { AgentMessage, FinishReason, Provider, ToolCall, ToolResult } from "./providers/types.js";
7
7
  import { type Tool } from "./tools/registry.js";
8
+ import { type ToolConcurrency } from "./tools/concurrency.js";
8
9
  /** What {@link runAgent} and {@link runConversation} share. */
9
10
  export interface LoopRunOptions {
10
11
  /**
@@ -30,12 +31,29 @@ export interface LoopRunOptions {
30
31
  * than throwing. Defaults to `RUNNER_RUN_TIMEOUT_MS` if set, else 10min.
31
32
  */
32
33
  maxDurationMs?: number;
34
+ /**
35
+ * Cost budget for the whole loop in US dollars, counted from the cost each
36
+ * model call reports. Absent (the default) means unlimited. When reached, the
37
+ * run stops before running the tools the last turn asked for, with
38
+ * `outcome: "cost_exhausted"` and the accumulated prose. See
39
+ * `LoopLimits.maxCostUsd` for what counts and what an unpriced call does.
40
+ */
41
+ maxCostUsd?: number;
33
42
  /**
34
43
  * Tell the model, on every step, how many steps and how much time remain.
35
44
  * Off by default. Advisory only: the loop's limits stay hard. See
36
45
  * `LoopLimits.budgetVisible`.
37
46
  */
38
47
  budgetVisible?: boolean;
48
+ /**
49
+ * Check each tool call's arguments against the tool's `inputSchema` before
50
+ * running it. Off by default. A call that does not fit is not run: the model
51
+ * gets an error result naming the failing arguments, `tool.failed` reports
52
+ * `reason: "invalid_input"`, and a terminal call refused this way does not
53
+ * end the run. Applies to every tool the run has, connector tools included;
54
+ * see `validateToolInput` for the subset of JSON Schema it checks.
55
+ */
56
+ validateToolInput?: boolean;
39
57
  /**
40
58
  * Cancels the run. On abort the in-flight model request and tool are
41
59
  * stopped, no summary call is made, and the run resolves (never rejects)
@@ -44,6 +62,11 @@ export interface LoopRunOptions {
44
62
  signal?: AbortSignal;
45
63
  /** The run's id, handed to tools on `ToolContext.runId`. */
46
64
  runId?: string;
65
+ /**
66
+ * Run a step's concurrency-safe tool calls in parallel. Off by default:
67
+ * every call runs in series, in the model's order. See `ToolConcurrency`.
68
+ */
69
+ toolConcurrency?: ToolConcurrency;
47
70
  log?: (line: string) => void;
48
71
  /**
49
72
  * Structured event sink. Absent (the default) means the loop behaves exactly
@@ -128,6 +151,7 @@ export interface ConversationResult {
128
151
  export declare function resolveLoopLimits(limits?: {
129
152
  maxSteps?: number | undefined;
130
153
  maxDurationMs?: number | undefined;
154
+ maxCostUsd?: number | undefined;
131
155
  budgetVisible?: boolean | undefined;
132
156
  }): LoopLimits;
133
157
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"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"}