@inflexa-ai/harness 0.30.0 → 0.30.2

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/dist/index.d.ts CHANGED
@@ -67,9 +67,9 @@ export { createConfiguredAiSdkProvider, DEFAULT_MAX_OUTPUT_TOKENS } from "./prov
67
67
  export type { AiSdkProviderConfig, ConfiguredAiSdkProviderDeps } from "./providers/ai-sdk.js";
68
68
  export { createEmbeddingProvider } from "./providers/embedding.js";
69
69
  export type { EmbeddingProviderDeps } from "./providers/embedding.js";
70
- export type { AgentChat, ChatProvider, EmbeddingProvider, ChatRequest, ChatResponse, ChatStreamEvent, ChatUsage, ModelMessage, PromptCachePolicy, ReasoningEffort, ReasoningPolicy, } from "./providers/types.js";
70
+ export type { AgentChat, ChatProvider, EmbeddingProvider, ChatRequest, ChatResponse, ChatStreamEvent, ChatUsage, ModelMessage, PromptCachePolicy, ReasoningPolicy, } from "./providers/types.js";
71
71
  export { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "./providers/prompt-cache.js";
72
- export { DEFAULT_REASONING, reasoningProviderOptions } from "./providers/reasoning.js";
72
+ export { DEFAULT_REASONING } from "./providers/reasoning.js";
73
73
  export { toProviderError, isProviderError } from "./providers/errors.js";
74
74
  export type { ProviderError } from "./providers/errors.js";
75
75
  export { createStreamingChat } from "./providers/streaming-chat.js";
package/dist/index.js CHANGED
@@ -104,13 +104,15 @@ export { createEmbeddingProvider } from "./providers/embedding.js";
104
104
  // `"off"`; the `cortex.harness.agent.cache_*_tokens` metrics show which case a
105
105
  // deployment is actually in.
106
106
  export { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "./providers/prompt-cache.js";
107
- // Reasoning depth. `ReasoningPolicy` is the vendor-neutral directive for how
108
- // deep a model reasons; a composition root sets it per run through
109
- // `RunAgentOptions.reasoning`. It defaults to `DEFAULT_REASONING` — adaptive
110
- // thinking at the `xhigh` effort — because an agent loop drives tools over many
111
- // iterations, and a shallow turn there wastes more calls than the deeper turn
112
- // costs in tokens. A host on a model with no reasoning support passes `"off"`.
113
- export { DEFAULT_REASONING, reasoningProviderOptions } from "./providers/reasoning.js";
107
+ // Reasoning depth. `ReasoningPolicy` is the vendor-neutral name for how deep a
108
+ // model reasons; a composition root sets it per run through
109
+ // `RunAgentOptions.reasoning`. It defaults to `DEFAULT_REASONING` — `xhigh` —
110
+ // because an agent loop drives tools over many iterations, and a shallow turn
111
+ // there wastes more calls than the deeper turn costs in tokens. The provider
112
+ // package resolves the name for the model that it is bound to, thus a model
113
+ // that accepts no `xhigh` gets the nearest name that it accepts. A host on a
114
+ // model with no reasoning support passes `"provider-default"`.
115
+ export { DEFAULT_REASONING } from "./providers/reasoning.js";
114
116
  // Provider error channel. `ProviderError` is the value `chat`/`embed` fail
115
117
  // with; `toProviderError` is its sole constructor. Exposed so an embedder
116
118
  // realizing its own `ChatProvider`/`EmbeddingProvider` fails with the exact
@@ -76,14 +76,15 @@ export interface RunAgentOptions {
76
76
  readonly promptCache?: PromptCachePolicy;
77
77
  /**
78
78
  * Reasoning policy for every LLM call this run makes. Defaults to
79
- * `DEFAULT_REASONING` (adaptive thinking at `xhigh`) — an agent loop drives
80
- * tools over many iterations, and a shallow turn there costs more in wasted
81
- * calls than the deeper turn costs in tokens.
79
+ * `DEFAULT_REASONING` (`xhigh`) — an agent loop drives tools over many
80
+ * iterations, and a shallow turn there costs more in wasted calls than the
81
+ * deeper turn costs in tokens. The provider package resolves the name for
82
+ * the model that it is bound to.
82
83
  *
83
84
  * The policy lives here, on the run, rather than on the provider, for the
84
85
  * same reason that the cache policy does: a one-shot LLM call elsewhere has
85
86
  * its own depth needs and must not inherit the depth of a loop. A host on a
86
- * model with no reasoning support passes `"off"`.
87
+ * model with no reasoning support passes `"provider-default"`.
87
88
  */
88
89
  readonly reasoning?: ReasoningPolicy;
89
90
  /**
@@ -8,7 +8,7 @@ import { markInterruptedMessage, syntheticUserMessage } from "../memory/ai-sdk-m
8
8
  import { stripUnansweredToolCalls } from "../memory/tool-call-integrity.js";
9
9
  import { classifyProviderError } from "../providers/errors.js";
10
10
  import { DEFAULT_PROMPT_CACHE, promptCacheProviderOptions } from "../providers/prompt-cache.js";
11
- import { DEFAULT_REASONING, mergeProviderOptions, reasoningProviderOptions } from "../providers/reasoning.js";
11
+ import { DEFAULT_REASONING } from "../providers/reasoning.js";
12
12
  import { resultStep } from "./run-step.js";
13
13
  import { AskRejectedError, UnavailableAsk } from "../tools/approval/contract.js";
14
14
  import { isToolError, readToolResultImages } from "../tools/define-tool.js";
@@ -85,7 +85,10 @@ export async function runAgent(agent, initial, session, opts) {
85
85
  // Resolved once, not per iteration: an identical options object across every
86
86
  // call is itself part of the cache contract — the request prefix has to be
87
87
  // byte-identical to be read back.
88
- const providerOptions = mergeProviderOptions(promptCacheProviderOptions(opts.promptCache ?? DEFAULT_PROMPT_CACHE), reasoningProviderOptions(opts.reasoning ?? DEFAULT_REASONING));
88
+ const providerOptions = promptCacheProviderOptions(opts.promptCache ?? DEFAULT_PROMPT_CACHE);
89
+ // The depth is a neutral name, and the provider package resolves it for the
90
+ // model. A vendor key here would turn that resolution off.
91
+ const reasoning = opts.reasoning ?? DEFAULT_REASONING;
89
92
  const usage = {};
90
93
  // Exactly one record per completed run — never one per iteration. That bound is
91
94
  // what keeps the default level affordable for an agent that runs long; the
@@ -233,6 +236,7 @@ export async function runAgent(agent, initial, session, opts) {
233
236
  tools: toolDefs,
234
237
  ...(opts.toolChoice !== undefined ? { toolChoice: opts.toolChoice } : {}),
235
238
  providerOptions,
239
+ reasoning,
236
240
  };
237
241
  const llmStepName = formatStepName.llm(i);
238
242
  const reply = await resultStep(runStep)(llmStepName, () => provider.chat(request, session, signal));
@@ -343,7 +347,7 @@ export async function runAgent(agent, initial, session, opts) {
343
347
  // because it is the one call whose write is pure waste, and the
344
348
  // cache_write_tokens counter is what makes that waste visible.
345
349
  const wrapUpStepName = formatStepName.llm(agent.maxIterations);
346
- const wrapUp = await resultStep(runStep)(wrapUpStepName, () => provider.chat({ system: agent.systemPrompt, messages, tools: {}, toolChoice: "none", providerOptions }, session, signal));
350
+ const wrapUp = await resultStep(runStep)(wrapUpStepName, () => provider.chat({ system: agent.systemPrompt, messages, tools: {}, toolChoice: "none", providerOptions, reasoning }, session, signal));
347
351
  accountForCall(wrapUp, wrapUpStepName);
348
352
  if (wrapUp.finishReason === "aborted") {
349
353
  // An abort during the tool-less wrap-up is still the user cutting the turn — the
@@ -1,4 +1,4 @@
1
- import { generateText, streamText, wrapLanguageModel, } from "ai";
1
+ import { streamText, wrapLanguageModel, } from "ai";
2
2
  import { createAnthropic } from "@ai-sdk/anthropic";
3
3
  import { createOpenAI } from "@ai-sdk/openai";
4
4
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
@@ -255,10 +255,10 @@ function requestedModelIdOf(model) {
255
255
  }
256
256
  /**
257
257
  * Capture the served model id at the raw model boundary, because that is the
258
- * only place it is still distinguishable from the requested one: `generateText`
259
- * and `streamText` both coalesce an unreported response model id to the bound
260
- * model's own id, so reading `result.response.modelId` would silently republish
261
- * the requested id as an endpoint's claim. A middleware sees the provider's
258
+ * only place it is still distinguishable from the requested one: `streamText`
259
+ * coalesces an unreported response model id to the bound model's own id, so
260
+ * reading `result.response.modelId` would silently republish the requested id
261
+ * as an endpoint's claim. A middleware sees the provider's
262
262
  * result before that fallback applies, so "reported nothing" stays absent.
263
263
  *
264
264
  * Built per call, never per provider: one provider instance serves concurrent
@@ -303,16 +303,6 @@ function responseFromMessages(input) {
303
303
  }
304
304
  return { message, finishReason, rawFinishReason, usage, requestedModelId, servedModelId };
305
305
  }
306
- function responseFromGenerate(result, identity) {
307
- return responseFromMessages({
308
- messages: result.responseMessages,
309
- fallbackText: result.text,
310
- finishReason: result.finishReason,
311
- rawFinishReason: result.rawFinishReason,
312
- usage: result.usage,
313
- ...identity,
314
- });
315
- }
316
306
  /**
317
307
  * Drop empty text parts — and messages left with no content at all — before
318
308
  * the wire call. The Anthropic Messages API rejects any request containing an
@@ -356,9 +346,57 @@ async function pullNextDelta(iterator) {
356
346
  return { kind: "delta", text: part.text };
357
347
  if (part.type === "abort")
358
348
  return { kind: "aborted", reason: part.reason };
349
+ // The SDK reports a wire failure as a part, not as a rejection, and it
350
+ // then closes the stream. Skipping it would turn a failed call into an
351
+ // empty assistant turn that the loop reads as a real answer.
352
+ if (part.type === "error")
353
+ return { kind: "failed", error: part.error };
359
354
  }
360
355
  return { kind: "end" };
361
356
  }
357
+ /**
358
+ * The HTTP status that each Anthropic stream error type stands for.
359
+ *
360
+ * A provider names a mid-stream failure in the payload, not in the status line:
361
+ * a stream that carries `overloaded_error` reports the condition that a 529
362
+ * names on the non-streaming wire. The classifier reads the status alone, thus
363
+ * the part has to reach it as an error that names one, or the same condition
364
+ * would classify one way on `chat` and another way on `chatStream`.
365
+ */
366
+ const STREAM_ERROR_STATUS = {
367
+ invalid_request_error: 400,
368
+ authentication_error: 401,
369
+ permission_error: 403,
370
+ not_found_error: 404,
371
+ request_too_large: 413,
372
+ rate_limit_error: 429,
373
+ timeout_error: 408,
374
+ api_error: 500,
375
+ overloaded_error: 529,
376
+ };
377
+ /**
378
+ * The failure that an `error` part of the SDK stream stands for.
379
+ *
380
+ * The part carries whatever the provider put on the wire, and that is often a
381
+ * bare object rather than an `Error`. Left as it is, it reaches the message as
382
+ * `[object Object]` and reaches the classifier with no status at all.
383
+ */
384
+ function streamPartFailure(error) {
385
+ if (error instanceof Error)
386
+ return error;
387
+ if (typeof error !== "object" || error === null)
388
+ return new Error(String(error));
389
+ const reported = error;
390
+ const type = typeof reported.type === "string" ? reported.type : undefined;
391
+ const message = typeof reported.message === "string" ? reported.message : undefined;
392
+ const failure = new Error(message ?? type ?? "The provider reported a stream error.", { cause: error });
393
+ const status = type === undefined ? undefined : STREAM_ERROR_STATUS[type];
394
+ // `extractStatus` walks the chain for this field, thus the named condition
395
+ // classifies the same way it would from a status line.
396
+ if (status !== undefined)
397
+ Object.assign(failure, { status });
398
+ return failure;
399
+ }
362
400
  /**
363
401
  * The failure that an `abort` part of the SDK stream stands for.
364
402
  *
@@ -394,16 +432,46 @@ export function createAiSdkProvider(deps) {
394
432
  const requestTimeoutMs = deps.requestTimeoutMs;
395
433
  const requestedModelId = requestedModelIdOf(deps.model);
396
434
  routeSdkWarningsTo(logger);
435
+ /**
436
+ * Record a call that the envelope gave up on.
437
+ *
438
+ * The envelope reports one attempt at `debug`, thus a deployment that runs
439
+ * at `info` keeps no trace of a call that exhausted its retries: the reply
440
+ * carries the failure to the user and the operator sees nothing. This is the
441
+ * one record that names the outcome.
442
+ */
443
+ function logFailure(session, failure, thrown) {
444
+ logger.error("provider call failed", {
445
+ workload: workloadOf(session),
446
+ kind: failure.type,
447
+ retryable: failure.retryable,
448
+ ...logger.errorFields(thrown),
449
+ });
450
+ }
397
451
  function chat(req, session, signal) {
398
452
  const run = async () => {
399
453
  const retry = createRetry(signal, logger, maxRetries);
400
454
  const capture = captureServedModelId(deps.model);
401
455
  try {
402
- const result = await retry(async () => {
456
+ const collected = await retry(async () => {
403
457
  // Attribution headers are time-limited; resolving them inside the
404
458
  // retried closure keeps them fresh across a multi-minute window.
405
459
  const headers = await resolveBillingHeaders(deps.resolveBilling, session);
406
- return generateText({
460
+ // The call streams on the wire and collapses below.
461
+ //
462
+ // A non-streaming turn sends no header until the model completes,
463
+ // thus the `headersTimeout` of the host HTTP client bounds the whole
464
+ // generation. No option of the SDK lifts that bound, because it sits
465
+ // under the fetch. Node defaults the field to 300s, and a turn near
466
+ // 18k output tokens reaches it, while `DEFAULT_MAX_OUTPUT_TOKENS`
467
+ // asks for up to 64k. A cut turn then costs twice: the envelope
468
+ // starts the same generation again from zero.
469
+ //
470
+ // Anthropic states the rule for its own SDKs: a large `max_tokens`
471
+ // makes streaming necessary, to prevent an HTTP timeout. Thus a
472
+ // ceiling this size streams whether or not a consumer wants the
473
+ // deltas, and `chatStream` stays the seam that forwards them.
474
+ const result = streamText({
407
475
  model: capture.model,
408
476
  system: req.system,
409
477
  messages: sanitizeMessages(req.messages),
@@ -416,19 +484,53 @@ export function createAiSdkProvider(deps) {
416
484
  maxRetries: 0,
417
485
  headers,
418
486
  abortSignal: signal,
419
- // No SDK timeout rides here. `chunkMs` bounds a stream only,
420
- // thus it is inert on this call. The fetch guard is the full
421
- // bound, because the headers of a non-streaming response arrive
422
- // when the model completes.
487
+ // `firstChunkMs` bounds the wait for the first content chunk,
488
+ // and `chunkMs` bounds each later gap. Thus each silent interval
489
+ // carries the one configured bound, and a turn that writes
490
+ // steadily runs as long as it needs.
491
+ ...(requestTimeoutMs !== undefined ? { timeout: { firstChunkMs: requestTimeoutMs, chunkMs: requestTimeoutMs } } : {}),
423
492
  providerOptions: req.providerOptions,
493
+ reasoning: req.reasoning,
424
494
  });
495
+ // The drain sits inside the retried closure, thus a failure at any
496
+ // point of the stream re-runs the whole attempt and the envelope
497
+ // keeps its whole-call scope. No delta reaches a consumer here —
498
+ // `chatStream` is the seam that forwards them.
499
+ const iterator = result.fullStream[Symbol.asyncIterator]();
500
+ let text = "";
501
+ for (let pull = await pullNextDelta(iterator); pull.kind !== "end"; pull = await pullNextDelta(iterator)) {
502
+ // A caller abort rides out as the reason of its own signal, thus
503
+ // it stays a cancellation and reaches the caller as a throw.
504
+ if (pull.kind === "aborted")
505
+ throw abortedStreamFailure(signal, pull.reason, requestTimeoutMs);
506
+ if (pull.kind === "failed")
507
+ throw streamPartFailure(pull.error);
508
+ text += pull.text;
509
+ }
510
+ // These promises settle only once the stream is fully drained, thus
511
+ // the loop above must reach its end before this point.
512
+ return {
513
+ messages: await result.responseMessages,
514
+ fallbackText: text,
515
+ finishReason: await result.finishReason,
516
+ rawFinishReason: await result.rawFinishReason,
517
+ usage: await result.usage,
518
+ };
425
519
  });
426
- return ok(responseFromGenerate(result, { requestedModelId, servedModelId: capture.servedModelId() }));
520
+ return ok(responseFromMessages({
521
+ ...collected,
522
+ requestedModelId,
523
+ // Read after the drain: the metadata chunk that carries the
524
+ // served id reaches the capture only as the stream is consumed.
525
+ servedModelId: capture.servedModelId(),
526
+ }));
427
527
  }
428
528
  catch (e) {
429
529
  if (isAbortError(e) || signal?.aborted)
430
530
  throw e;
431
- return err(toProviderError(unwrapForClassification(e), workloadOf(session)));
531
+ const failure = toProviderError(unwrapForClassification(e), workloadOf(session));
532
+ logFailure(session, failure, e);
533
+ return err(failure);
432
534
  }
433
535
  };
434
536
  return new ResultAsync(run());
@@ -467,6 +569,7 @@ export function createAiSdkProvider(deps) {
467
569
  // thus it feeds neither bound.
468
570
  ...(requestTimeoutMs !== undefined ? { timeout: { firstChunkMs: requestTimeoutMs, chunkMs: requestTimeoutMs } } : {}),
469
571
  providerOptions: req.providerOptions,
572
+ reasoning: req.reasoning,
470
573
  });
471
574
  const iterator = result.fullStream[Symbol.asyncIterator]();
472
575
  const first = await pullNextDelta(iterator);
@@ -475,6 +578,8 @@ export function createAiSdkProvider(deps) {
475
578
  // fresh window under the retry policy of a connection error.
476
579
  if (first.kind === "aborted")
477
580
  throw abortedStreamFailure(signal, first.reason, requestTimeoutMs);
581
+ if (first.kind === "failed")
582
+ throw streamPartFailure(first.error);
478
583
  if (first.kind === "end") {
479
584
  // A doStream promise rejection — the shape a real connection
480
585
  // failure takes — does NOT surface at this first pull: streamText
@@ -520,6 +625,8 @@ export function createAiSdkProvider(deps) {
520
625
  // terminal `ProviderError` from the throwable.
521
626
  if (pull.kind === "aborted")
522
627
  throw abortedStreamFailure(signal, pull.reason, requestTimeoutMs);
628
+ if (pull.kind === "failed")
629
+ throw streamPartFailure(pull.error);
523
630
  text += pull.text;
524
631
  yield { type: "text-delta", text: pull.text };
525
632
  }
@@ -539,7 +646,9 @@ export function createAiSdkProvider(deps) {
539
646
  catch (e) {
540
647
  if (isAbortError(e) || signal?.aborted)
541
648
  throw e;
542
- throw toProviderError(unwrapForClassification(e), workloadOf(session));
649
+ const failure = toProviderError(unwrapForClassification(e), workloadOf(session));
650
+ logFailure(session, failure, e);
651
+ throw failure;
543
652
  }
544
653
  }
545
654
  // The provider advertises the guard limit on its own instance, thus a
@@ -622,39 +731,6 @@ function withStoreDirective(model, store) {
622
731
  },
623
732
  });
624
733
  }
625
- /**
626
- * Mirror the neutral reasoning effort into the namespace of an
627
- * OpenAI-compatible connection.
628
- *
629
- * The compatible provider reads `providerOptions[<the name of the connection>]`
630
- * rather than `providerOptions.openai`, and it derives that namespace from the
631
- * first dot-separated segment of the name. The loop cannot write that key,
632
- * because only this seam knows the name. Thus the middleware copies
633
- * `reasoningEffort` across at request time. A value that the caller already set
634
- * on the namespace wins, and a request with no effort passes through untouched.
635
- */
636
- function withCompatibleReasoning(model, name) {
637
- const namespace = name.split(".")[0]?.trim();
638
- if (namespace === undefined || namespace === "")
639
- return model;
640
- return wrapLanguageModel({
641
- model,
642
- middleware: {
643
- transformParams: async ({ params }) => {
644
- const effort = params.providerOptions?.["openai"]?.["reasoningEffort"];
645
- if (effort === undefined)
646
- return params;
647
- return {
648
- ...params,
649
- providerOptions: {
650
- ...params.providerOptions,
651
- [namespace]: { reasoningEffort: effort, ...params.providerOptions?.[namespace] },
652
- },
653
- };
654
- },
655
- },
656
- });
657
- }
658
734
  /**
659
735
  * Realize an `AiSdkProviderConfig` into a `ChatProvider` bound to that config's
660
736
  * connection and model. The model is closed into the returned provider here (it
@@ -735,7 +811,9 @@ export function createConfiguredAiSdkProvider(deps) {
735
811
  fetch: effectiveFetch,
736
812
  });
737
813
  return createAiSdkProvider({
738
- model: withCompatibleReasoning(provider.chatModel(config.model), config.name),
814
+ // The compatible package reads the neutral `reasoning` of the call, thus
815
+ // the seam needs no middleware to carry the depth into its namespace.
816
+ model: provider.chatModel(config.model),
739
817
  resolveBilling: deps.resolveBilling,
740
818
  capabilities: config.capabilities,
741
819
  logger: deps.logger,
@@ -1,35 +1,18 @@
1
1
  /**
2
- * Reasoning policy → provider wire options.
2
+ * The reasoning depth of a run.
3
3
  *
4
- * This is the **only** place in the harness that names a vendor for reasoning
5
- * depth. Everything upstream — `ReasoningPolicy` and `RunAgentOptions` — speaks
6
- * the neutral harness concept. This module translates it one time, at the
7
- * provider seam, thus the loop never learns which vendor it talks to. It is the
8
- * sibling of `prompt-cache.ts`, and it obeys the same rules.
4
+ * The harness states the depth one time, on `ChatRequest.reasoning`, with the
5
+ * neutral names of the AI SDK. It names no vendor key. Each provider package
6
+ * holds the table of what its own models accept, and it maps the neutral name
7
+ * onto the wire. The Anthropic package also selects adaptive thinking there.
9
8
  *
10
- * ## Why the emitted options are safe on every provider
9
+ * ## Why the harness names no vendor key
11
10
  *
12
- * `providerOptions` of the AI SDK is a namespaced bag. Each provider reads only
13
- * `providerOptions[<its own name>]`, and it ignores each other key. Thus the
14
- * `anthropic` namespace is inert on an OpenAI model, and the `openai` namespace
15
- * is inert on an Anthropic model. Neither one is an error.
16
- *
17
- * ## What each namespace does
18
- *
19
- * The Anthropic namespace carries two keys, because the vendor splits the
20
- * concept in two:
21
- *
22
- * - `thinking: { type: "adaptive" }` says that the model reasons, and it lets
23
- * the model choose the depth for each turn. A fixed `budgetTokens` is removed
24
- * on the current models, and it returns a 400 there.
25
- * - `effort` says how deep the model reasons and how much it spends in total.
26
- * The vendor default is `high`.
27
- *
28
- * The OpenAI namespace carries one key, `reasoningEffort`, which takes the same
29
- * five values. An OpenAI-compatible endpoint reads the same key, but from a
30
- * namespace that carries the *name of the connection* rather than `openai`. The
31
- * provider seam mirrors the value into that namespace, because only the seam
32
- * knows the name (`providers/ai-sdk.ts`).
11
+ * The harness wrote `providerOptions.anthropic.effort` before. A value on that
12
+ * key turns the per-model table of the Anthropic package off, thus the raw name
13
+ * reached the wire. A model that accepts `high` but not `xhigh` answered 400.
14
+ * The neutral field has no such hazard, because the package resolves the name
15
+ * for the model that it is bound to.
33
16
  *
34
17
  * ## Where the policy belongs
35
18
  *
@@ -37,29 +20,16 @@
37
20
  * cache policy does. A one-shot LLM call elsewhere in the harness has its own
38
21
  * depth needs, and it must not inherit the depth of an agent loop.
39
22
  */
40
- import type { ProviderOptions, ReasoningPolicy } from "./types.js";
23
+ import type { ReasoningPolicy } from "./types.js";
41
24
  /**
42
- * The default policy: adaptive thinking at the `xhigh` effort.
25
+ * The default policy: the deepest name of the neutral ladder.
43
26
  *
44
- * `xhigh` sits between `high` and `max`. It is the vendor recommendation for
45
- * coding and agentic work, and each agent of the harness drives tools over many
46
- * iterations. `max` costs more than the remaining quality is worth for a routine
47
- * turn, thus it is not the default. A host that wants a cheaper loop passes a
48
- * lower effort, and a host on a model with no reasoning support passes `"off"`.
49
- */
50
- export declare const DEFAULT_REASONING: ReasoningPolicy;
51
- /**
52
- * Translate a neutral reasoning policy into provider wire options.
27
+ * Each agent of the harness drives tools over many iterations. A shallow turn
28
+ * there wastes more calls than a deeper turn costs in tokens. The Anthropic
29
+ * package sends `xhigh` to a model that accepts it, and `max` to a model that
30
+ * does not. The OpenAI-compatible package sends the name as it is.
53
31
  *
54
- * Returns `undefined` for `"off"`, thus the caller can leave `providerOptions`
55
- * unset rather than send an empty bag.
32
+ * A host that wants a cheaper loop passes a lower name. A host on a model with
33
+ * no reasoning support passes `"provider-default"`.
56
34
  */
57
- export declare function reasoningProviderOptions(policy: ReasoningPolicy): ProviderOptions | undefined;
58
- /**
59
- * Merge two option bags one namespace at a time.
60
- *
61
- * A plain spread of the two bags drops a whole namespace when both sides carry
62
- * it, and the cache policy and the reasoning policy both write `anthropic`. The
63
- * right-hand bag wins for a key that both sides set.
64
- */
65
- export declare function mergeProviderOptions(left: ProviderOptions | undefined, right: ProviderOptions | undefined): ProviderOptions | undefined;
35
+ export declare const DEFAULT_REASONING: ReasoningPolicy;
@@ -1,35 +1,18 @@
1
1
  /**
2
- * Reasoning policy → provider wire options.
2
+ * The reasoning depth of a run.
3
3
  *
4
- * This is the **only** place in the harness that names a vendor for reasoning
5
- * depth. Everything upstream — `ReasoningPolicy` and `RunAgentOptions` — speaks
6
- * the neutral harness concept. This module translates it one time, at the
7
- * provider seam, thus the loop never learns which vendor it talks to. It is the
8
- * sibling of `prompt-cache.ts`, and it obeys the same rules.
4
+ * The harness states the depth one time, on `ChatRequest.reasoning`, with the
5
+ * neutral names of the AI SDK. It names no vendor key. Each provider package
6
+ * holds the table of what its own models accept, and it maps the neutral name
7
+ * onto the wire. The Anthropic package also selects adaptive thinking there.
9
8
  *
10
- * ## Why the emitted options are safe on every provider
9
+ * ## Why the harness names no vendor key
11
10
  *
12
- * `providerOptions` of the AI SDK is a namespaced bag. Each provider reads only
13
- * `providerOptions[<its own name>]`, and it ignores each other key. Thus the
14
- * `anthropic` namespace is inert on an OpenAI model, and the `openai` namespace
15
- * is inert on an Anthropic model. Neither one is an error.
16
- *
17
- * ## What each namespace does
18
- *
19
- * The Anthropic namespace carries two keys, because the vendor splits the
20
- * concept in two:
21
- *
22
- * - `thinking: { type: "adaptive" }` says that the model reasons, and it lets
23
- * the model choose the depth for each turn. A fixed `budgetTokens` is removed
24
- * on the current models, and it returns a 400 there.
25
- * - `effort` says how deep the model reasons and how much it spends in total.
26
- * The vendor default is `high`.
27
- *
28
- * The OpenAI namespace carries one key, `reasoningEffort`, which takes the same
29
- * five values. An OpenAI-compatible endpoint reads the same key, but from a
30
- * namespace that carries the *name of the connection* rather than `openai`. The
31
- * provider seam mirrors the value into that namespace, because only the seam
32
- * knows the name (`providers/ai-sdk.ts`).
11
+ * The harness wrote `providerOptions.anthropic.effort` before. A value on that
12
+ * key turns the per-model table of the Anthropic package off, thus the raw name
13
+ * reached the wire. A model that accepts `high` but not `xhigh` answered 400.
14
+ * The neutral field has no such hazard, because the package resolves the name
15
+ * for the model that it is bound to.
33
16
  *
34
17
  * ## Where the policy belongs
35
18
  *
@@ -38,44 +21,14 @@
38
21
  * depth needs, and it must not inherit the depth of an agent loop.
39
22
  */
40
23
  /**
41
- * The default policy: adaptive thinking at the `xhigh` effort.
24
+ * The default policy: the deepest name of the neutral ladder.
42
25
  *
43
- * `xhigh` sits between `high` and `max`. It is the vendor recommendation for
44
- * coding and agentic work, and each agent of the harness drives tools over many
45
- * iterations. `max` costs more than the remaining quality is worth for a routine
46
- * turn, thus it is not the default. A host that wants a cheaper loop passes a
47
- * lower effort, and a host on a model with no reasoning support passes `"off"`.
48
- */
49
- export const DEFAULT_REASONING = { effort: "xhigh" };
50
- /**
51
- * Translate a neutral reasoning policy into provider wire options.
52
- *
53
- * Returns `undefined` for `"off"`, thus the caller can leave `providerOptions`
54
- * unset rather than send an empty bag.
55
- */
56
- export function reasoningProviderOptions(policy) {
57
- if (policy === "off")
58
- return undefined;
59
- return {
60
- anthropic: { effort: policy.effort, thinking: { type: "adaptive" } },
61
- openai: { reasoningEffort: policy.effort },
62
- };
63
- }
64
- /**
65
- * Merge two option bags one namespace at a time.
26
+ * Each agent of the harness drives tools over many iterations. A shallow turn
27
+ * there wastes more calls than a deeper turn costs in tokens. The Anthropic
28
+ * package sends `xhigh` to a model that accepts it, and `max` to a model that
29
+ * does not. The OpenAI-compatible package sends the name as it is.
66
30
  *
67
- * A plain spread of the two bags drops a whole namespace when both sides carry
68
- * it, and the cache policy and the reasoning policy both write `anthropic`. The
69
- * right-hand bag wins for a key that both sides set.
31
+ * A host that wants a cheaper loop passes a lower name. A host on a model with
32
+ * no reasoning support passes `"provider-default"`.
70
33
  */
71
- export function mergeProviderOptions(left, right) {
72
- if (left === undefined)
73
- return right;
74
- if (right === undefined)
75
- return left;
76
- const merged = { ...left };
77
- for (const [namespace, options] of Object.entries(right)) {
78
- merged[namespace] = { ...merged[namespace], ...options };
79
- }
80
- return merged;
81
- }
34
+ export const DEFAULT_REASONING = "xhigh";
@@ -6,6 +6,7 @@
6
6
  * attribution is resolved lazily at the provider wire boundary.
7
7
  */
8
8
  import type { FinishReason, LanguageModel, ModelMessage, ToolSet } from "ai";
9
+ import type { LanguageModelV4CallOptions } from "@ai-sdk/provider";
9
10
  import type { ProviderOptions } from "@ai-sdk/provider-utils";
10
11
  import type { ResultAsync } from "neverthrow";
11
12
  import type { AgentSession } from "../auth/types.js";
@@ -40,6 +41,11 @@ export interface ChatRequest {
40
41
  readonly toolName: string;
41
42
  };
42
43
  readonly providerOptions?: ProviderOptions;
44
+ /**
45
+ * How deep the model reasons on this call. Absent sends no directive, thus
46
+ * the model applies its own default. Refer to `ReasoningPolicy`.
47
+ */
48
+ readonly reasoning?: ReasoningPolicy;
43
49
  }
44
50
  /**
45
51
  * Vendor-neutral prompt-cache policy — a harness concept, not a vendor one.
@@ -57,17 +63,23 @@ export type PromptCachePolicy = {
57
63
  /**
58
64
  * How deep a model reasons, in harness-neutral names.
59
65
  *
60
- * `{ effort }` asks the provider for that reasoning depth, and `"off"` sends no
61
- * reasoning directive at all. The five values are the ladder that both vendor
62
- * families accept. A model with no reasoning support ignores the directive
63
- * rather than fails on it, thus the policy is a no-op for that model. Refer to
64
- * `./reasoning.ts`, the single place where the harness translates this into
65
- * vendor wire options.
66
+ * This is the `reasoning` call setting of the AI SDK, and the harness passes the
67
+ * value through without a change. Each provider package maps the name onto its
68
+ * own wire shape, and each one holds the table of what its models accept. The
69
+ * Anthropic package selects adaptive thinking with the matching `effort`, and it
70
+ * lowers a level that the model does not accept. The OpenAI-compatible package
71
+ * sends the name as `reasoning_effort`.
72
+ *
73
+ * `"provider-default"` sends no directive, thus the model applies its own
74
+ * default. `"none"` asks for no reasoning. The other names are the ladder from
75
+ * the shallowest depth to the deepest depth.
76
+ *
77
+ * CAUTION: never write `providerOptions.anthropic.effort` or
78
+ * `providerOptions.openai.reasoningEffort` in place of this field. A value on
79
+ * either key turns the per-model table off, and the raw name then reaches the
80
+ * wire. A model that does not accept that name answers 400.
66
81
  */
67
- export type ReasoningEffort = "low" | "medium" | "high" | "xhigh" | "max";
68
- export type ReasoningPolicy = {
69
- readonly effort: ReasoningEffort;
70
- } | "off";
82
+ export type ReasoningPolicy = NonNullable<LanguageModelV4CallOptions["reasoning"]>;
71
83
  /**
72
84
  * Token accounting for one chat call, in harness-neutral names.
73
85
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inflexa-ai/harness",
3
- "version": "0.30.0",
3
+ "version": "0.30.2",
4
4
  "description": "Host-agnostic bioinformatics agent harness — hand-rolled agent loop, DBOS-durable workflows, sandboxed compute.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",