@rarebit-one/harness-kernel 0.15.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.
- package/README.md +268 -17
- package/dist/agent.d.ts +24 -0
- package/dist/agent.d.ts.map +1 -1
- package/dist/agent.js +138 -30
- package/dist/agent.js.map +1 -1
- package/dist/connectors/auth.d.ts +32 -0
- package/dist/connectors/auth.d.ts.map +1 -0
- package/dist/connectors/auth.js +85 -0
- package/dist/connectors/auth.js.map +1 -0
- package/dist/connectors/mcpClient.d.ts +14 -4
- package/dist/connectors/mcpClient.d.ts.map +1 -1
- package/dist/connectors/mcpClient.js +32 -11
- package/dist/connectors/mcpClient.js.map +1 -1
- package/dist/engines/acp.d.ts.map +1 -1
- package/dist/engines/acp.js +2 -1
- package/dist/engines/acp.js.map +1 -1
- package/dist/engines/agentSdk.d.ts +11 -0
- package/dist/engines/agentSdk.d.ts.map +1 -0
- package/dist/engines/agentSdk.js +26 -0
- package/dist/engines/agentSdk.js.map +1 -0
- package/dist/engines/agentSdkMissing.d.ts +6 -0
- package/dist/engines/agentSdkMissing.d.ts.map +1 -0
- package/dist/engines/agentSdkMissing.js +12 -0
- package/dist/engines/agentSdkMissing.js.map +1 -0
- package/dist/engines/capability.d.ts +1 -1
- package/dist/engines/capability.js +1 -1
- package/dist/engines/capabilityMcp.d.ts +14 -4
- package/dist/engines/capabilityMcp.d.ts.map +1 -1
- package/dist/engines/capabilityMcp.js +5 -3
- package/dist/engines/capabilityMcp.js.map +1 -1
- package/dist/engines/claudeCode.d.ts.map +1 -1
- package/dist/engines/claudeCode.js +2 -1
- package/dist/engines/claudeCode.js.map +1 -1
- package/dist/engines/claudeCodeDriver.d.ts +4 -3
- package/dist/engines/claudeCodeDriver.d.ts.map +1 -1
- package/dist/engines/claudeCodeDriver.js +13 -8
- package/dist/engines/claudeCodeDriver.js.map +1 -1
- package/dist/engines/codex.d.ts +4 -4
- package/dist/engines/codex.d.ts.map +1 -1
- package/dist/engines/codex.js +6 -5
- package/dist/engines/codex.js.map +1 -1
- package/dist/engines/codexDriver.d.ts +1 -1
- package/dist/engines/codexDriver.js +1 -1
- package/dist/engines/connectorMcp.d.ts +13 -0
- package/dist/engines/connectorMcp.d.ts.map +1 -1
- package/dist/engines/connectorMcp.js +23 -0
- package/dist/engines/connectorMcp.js.map +1 -1
- package/dist/engines/gateway.d.ts +5 -2
- package/dist/engines/gateway.d.ts.map +1 -1
- package/dist/engines/gateway.js +7 -2
- package/dist/engines/gateway.js.map +1 -1
- package/dist/engines/native.d.ts.map +1 -1
- package/dist/engines/native.js +10 -3
- package/dist/engines/native.js.map +1 -1
- package/dist/engines/types.d.ts +10 -1
- package/dist/engines/types.d.ts.map +1 -1
- package/dist/events.d.ts +22 -8
- package/dist/events.d.ts.map +1 -1
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +14 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/loop.d.ts +23 -0
- package/dist/loop.d.ts.map +1 -1
- package/dist/loop.js +1 -0
- package/dist/loop.js.map +1 -1
- package/dist/models/chat.d.ts +12 -6
- package/dist/models/chat.d.ts.map +1 -1
- package/dist/models/chat.js +11 -6
- package/dist/models/chat.js.map +1 -1
- package/dist/models/retry.d.ts +252 -0
- package/dist/models/retry.d.ts.map +1 -0
- package/dist/models/retry.js +400 -0
- package/dist/models/retry.js.map +1 -0
- package/dist/models/types.d.ts +7 -0
- package/dist/models/types.d.ts.map +1 -1
- package/dist/models/types.js.map +1 -1
- package/dist/private.d.ts.map +1 -1
- package/dist/private.js +12 -2
- package/dist/private.js.map +1 -1
- package/dist/providers/anthropic.d.ts +1 -2
- package/dist/providers/anthropic.d.ts.map +1 -1
- package/dist/providers/anthropic.js +0 -12
- package/dist/providers/anthropic.js.map +1 -1
- package/dist/providers/index.d.ts +1 -1
- package/dist/providers/index.d.ts.map +1 -1
- package/dist/providers/mock.d.ts +1 -2
- package/dist/providers/mock.d.ts.map +1 -1
- package/dist/providers/mock.js +14 -18
- package/dist/providers/mock.js.map +1 -1
- package/dist/providers/openai.d.ts +1 -2
- package/dist/providers/openai.d.ts.map +1 -1
- package/dist/providers/openai.js +0 -14
- package/dist/providers/openai.js.map +1 -1
- package/dist/providers/scripted.d.ts +1 -3
- package/dist/providers/scripted.d.ts.map +1 -1
- package/dist/providers/scripted.js +0 -9
- package/dist/providers/scripted.js.map +1 -1
- package/dist/providers/types.d.ts +0 -6
- package/dist/providers/types.d.ts.map +1 -1
- package/dist/secrets.d.ts +17 -7
- package/dist/secrets.d.ts.map +1 -1
- package/dist/secrets.js +38 -24
- package/dist/secrets.js.map +1 -1
- package/dist/tools/concurrency.d.ts +48 -0
- package/dist/tools/concurrency.d.ts.map +1 -0
- package/dist/tools/concurrency.js +64 -0
- package/dist/tools/concurrency.js.map +1 -0
- package/dist/tools/metadata.d.ts +18 -3
- package/dist/tools/metadata.d.ts.map +1 -1
- package/dist/tools/metadata.js +7 -0
- package/dist/tools/metadata.js.map +1 -1
- package/dist/tools/registry.d.ts +6 -4
- package/dist/tools/registry.d.ts.map +1 -1
- package/dist/tools/registry.js +5 -3
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/terminal.d.ts +8 -6
- package/dist/tools/terminal.d.ts.map +1 -1
- package/dist/tools/terminal.js +8 -6
- package/dist/tools/terminal.js.map +1 -1
- package/dist/tools/validate.d.ts +49 -0
- package/dist/tools/validate.d.ts.map +1 -0
- package/dist/tools/validate.js +346 -0
- package/dist/tools/validate.js.map +1 -0
- package/dist/transcript.d.ts +53 -0
- package/dist/transcript.d.ts.map +1 -0
- package/dist/transcript.js +104 -0
- package/dist/transcript.js.map +1 -0
- package/dist/types.d.ts +44 -5
- package/dist/types.d.ts.map +1 -1
- package/package.json +13 -5
package/README.md
CHANGED
|
@@ -36,18 +36,30 @@ new.
|
|
|
36
36
|
## Install
|
|
37
37
|
|
|
38
38
|
```bash
|
|
39
|
-
npm install @rarebit-one/harness-kernel
|
|
39
|
+
npm install @rarebit-one/harness-kernel
|
|
40
40
|
```
|
|
41
41
|
|
|
42
42
|
ESM-only, Node >= 22.
|
|
43
43
|
|
|
44
|
+
The `claude-code` engine drives `@anthropic-ai/claude-agent-sdk`, which is an
|
|
45
|
+
**optional peer dependency**: it is proprietary, under its vendor's own terms
|
|
46
|
+
rather than this package's MIT licence, and it carries the vendor CLI binary.
|
|
47
|
+
An application that runs that engine installs it itself:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npm install @anthropic-ai/claude-agent-sdk
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Every other engine and the whole native loop work without it. Running the
|
|
54
|
+
`claude-code` engine without it throws `AgentSdkMissingError`.
|
|
55
|
+
|
|
44
56
|
## What's in it
|
|
45
57
|
|
|
46
58
|
| Area | Exports |
|
|
47
59
|
|------|---------|
|
|
48
60
|
| **Model seam** | `ModelInvocation` — one arrow for every model kind, with declared capabilities, a uniform result envelope and `probe()` |
|
|
49
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) |
|
|
50
|
-
| **Agent loop** | `runConversation()` — a provider-neutral tool-use loop over a seeded conversation, with step
|
|
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 |
|
|
51
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 |
|
|
52
64
|
| **Run events** | `RunEvent` — an ordered, structured account of a run (turns, tool calls, budgets, outcome), emitted to an optional sink |
|
|
53
65
|
| **Tools** | `Tool` (+ `ToolContext`), `primitiveTools()` (`run_code`, `read_file`, `list_files`, `http_fetch`), `connectorTools()`, `modelAsTool()`, metadata + projections (`replaySafeTools`, …) |
|
|
@@ -239,7 +251,7 @@ Every entry point takes an optional `AbortSignal` — `runAgent({ signal })`,
|
|
|
239
251
|
const controller = new AbortController()
|
|
240
252
|
const result = await engine.run(spec, { log, signal: controller.signal })
|
|
241
253
|
// later: controller.abort("user canceled")
|
|
242
|
-
result.outcome // "completed" | "steps_exhausted" | "timed_out" | "canceled" | "failed"
|
|
254
|
+
result.outcome // "completed" | "steps_exhausted" | "timed_out" | "cost_exhausted" | "canceled" | "failed" | "suspended"
|
|
243
255
|
```
|
|
244
256
|
|
|
245
257
|
The native loop merges it with its own wall-clock deadline and threads the
|
|
@@ -346,7 +358,7 @@ const result = await runConversation({
|
|
|
346
358
|
providerOptions: { promptCache: {} },
|
|
347
359
|
})
|
|
348
360
|
|
|
349
|
-
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"
|
|
350
362
|
result.terminal?.input // { text, quick_replies } when the model called `reply`
|
|
351
363
|
result.messages // store this; seed the next turn with it
|
|
352
364
|
result.pending // the external calls to execute when outcome is "suspended"
|
|
@@ -361,8 +373,39 @@ so the transcript carries no dangling call, and then ends the run with
|
|
|
361
373
|
`stopReason: "terminal_tool"` and the call on `result.terminal`. An executor
|
|
362
374
|
that throws turns the call into an ordinary tool error the model gets to
|
|
363
375
|
correct. `terminalTool()` builds one with a no-op executor; the structured
|
|
364
|
-
answer is the call's `input
|
|
365
|
-
schema
|
|
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.
|
|
366
409
|
|
|
367
410
|
The seed must end with a user message or with the tool results the model is
|
|
368
411
|
waiting for; the loop copies it and never mutates the array it was handed.
|
|
@@ -405,7 +448,7 @@ if (parked.outcome === "suspended") {
|
|
|
405
448
|
system,
|
|
406
449
|
messages: withToolResults(parked.messages, results),
|
|
407
450
|
tools: [...tools, askHuman], // keep it: the model may ask again
|
|
408
|
-
...remainingLimits({ maxSteps, maxDurationMs }, parked.consumed),
|
|
451
|
+
...remainingLimits({ maxSteps, maxDurationMs, maxCostUsd }, parked.consumed),
|
|
409
452
|
})
|
|
410
453
|
}
|
|
411
454
|
```
|
|
@@ -418,16 +461,106 @@ closes each parked call in its own event stream, at step 0, as `tool.succeeded`
|
|
|
418
461
|
or `tool.failed` with `reason: "rejected"`, so a consumer following the stream
|
|
419
462
|
across both runs sees every `tool.pending` matched. `remainingLimits` carries
|
|
420
463
|
the budget over, floored at one step and one minute so the first resumed step
|
|
421
|
-
always runs
|
|
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
|
|
422
466
|
answer or a stop in the same step ends the run instead, and the parked calls
|
|
423
467
|
are answered as not run so the transcript stays whole.
|
|
424
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
|
+
|
|
425
508
|
**Budget visibility.** `budgetVisible: true` (off by default) appends one
|
|
426
509
|
ephemeral user line to every request, `[budget] N of M step(s) and Ss remain`,
|
|
427
|
-
counting down
|
|
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
|
|
428
512
|
history so the cached prefix (system, tools, earlier turns) is untouched. The
|
|
429
513
|
limits stay hard either way; the line only lets the model pace itself.
|
|
430
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
|
+
|
|
431
564
|
### Provider options and prompt caching
|
|
432
565
|
|
|
433
566
|
`providerOptions` rides on every model call a run makes and each adapter maps it
|
|
@@ -486,8 +619,26 @@ Usage carries what the provider prices: beside the token counts, `TokenUsage`
|
|
|
486
619
|
has `cacheWriteTokens`, `reasoningTokens` and `reportedCost`
|
|
487
620
|
(`{ amount, currency: "USD" }`, from OpenRouter's `usage.cost`) when the
|
|
488
621
|
response reports them, on every `model.turn` event and summed by amount on the
|
|
489
|
-
result
|
|
490
|
-
|
|
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.
|
|
491
642
|
|
|
492
643
|
Two more per-call knobs: `maxRetries` overrides the SDK retry count for one
|
|
493
644
|
call (set `0` alongside an OpenRouter `models` chain, or the SDK and the router
|
|
@@ -496,6 +647,101 @@ both retry), and the `openai` adapter sends the cap as `max_completion_tokens`
|
|
|
496
647
|
local servers get `max_tokens`. `connectorTools(..., { toolName })` names MCP
|
|
497
648
|
tools your way when the default `<connector>__<tool>` prefix is in the way.
|
|
498
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
|
+
|
|
499
745
|
### Recorded evals with `ScriptedProvider`
|
|
500
746
|
|
|
501
747
|
```ts
|
|
@@ -528,11 +774,15 @@ would defeat the point.
|
|
|
528
774
|
### Engines
|
|
529
775
|
|
|
530
776
|
An engine is a pluggable agent harness. `native` is the in-process tool-use loop
|
|
531
|
-
above; `claude-code` and `codex` shell out to those CLIs/SDKs.
|
|
532
|
-
one capability
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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.
|
|
536
786
|
|
|
537
787
|
```ts
|
|
538
788
|
import { selectEngine } from "@rarebit-one/harness-kernel"
|
|
@@ -684,7 +934,8 @@ checks.
|
|
|
684
934
|
A `vm` run is also refused when it holds **exposed** credentials, which no
|
|
685
935
|
gateway can inject and which therefore reach the guest in the clear: non-empty
|
|
686
936
|
`secrets`, or a connector credential the harness carries itself (`auth.token`,
|
|
687
|
-
or `headers` / `env` values on a caller's connector
|
|
937
|
+
an `auth.headers` function, or `headers` / `env` values on a caller's connector
|
|
938
|
+
shape). A connector whose
|
|
688
939
|
token is the gateway's run token is routed, not exposed. The caller's policy can
|
|
689
940
|
lift this with `RunSpec.exposedCredentials: "allowed"`; absent, it refuses.
|
|
690
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
|
/**
|
package/dist/agent.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,KAAK,QAAQ,EACb,KAAK,YAAY,EACjB,KAAK,UAAU,EACf,KAAK,UAAU,EAEhB,MAAM,aAAa,CAAA;AACpB,OAAO,EAGL,KAAK,YAAY,EACjB,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,WAAW,EAChB,KAAK,UAAU,EACf,KAAK,YAAY,EAClB,MAAM,WAAW,CAAA;AAClB,OAAO,EAA4B,KAAK,SAAS,EAAE,MAAM,kBAAkB,CAAA;AAE3E,OAAO,KAAK,EAAiB,UAAU,EAAS,MAAM,mBAAmB,CAAA;AACzE,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAA;AAC7D,OAAO,KAAK,EACV,YAAY,EACZ,YAAY,EACZ,QAAQ,EACR,QAAQ,EACR,UAAU,EAEX,MAAM,sBAAsB,CAAA;AAC7B,OAAO,EAAe,KAAK,IAAI,EAAE,MAAM,qBAAqB,CAAA;
|
|
1
|
+
{"version":3,"file":"agent.d.ts","sourceRoot":"","sources":["../src/agent.ts"],"names":[],"mappings":"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"}
|