@theoremjs/agents 0.3.4-next.484.g20d8b9d → 0.3.4-next.522.g9e684cd

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 (95) hide show
  1. package/README.md +61 -51
  2. package/esm/mod.d.ts +2 -2
  3. package/esm/mod.js +1 -1
  4. package/esm/src/guardrails/credential-scan.d.ts +1 -1
  5. package/esm/src/guardrails/credential-scan.js +41 -18
  6. package/esm/src/guardrails/destinations.js +6 -2
  7. package/esm/src/guardrails/detect-at.d.ts +5 -5
  8. package/esm/src/guardrails/detect-at.js +51 -41
  9. package/esm/src/guardrails/detect-reply.d.ts +1 -1
  10. package/esm/src/guardrails/detect-reply.js +4 -2
  11. package/esm/src/guardrails/egress-automata.js +203 -202
  12. package/esm/src/guardrails/egress-compiler.js +1 -4
  13. package/esm/src/guardrails/egress-stream.js +10 -3
  14. package/esm/src/guardrails/egress-urls.js +4 -2
  15. package/esm/src/guardrails/error.d.ts +1 -1
  16. package/esm/src/guardrails/error.js +19 -2
  17. package/esm/src/guardrails/host-patterns.d.ts +1 -4
  18. package/esm/src/guardrails/host-patterns.js +1 -10
  19. package/esm/src/guardrails/injection-patterns.d.ts +3 -1
  20. package/esm/src/guardrails/injection-patterns.js +87 -4
  21. package/esm/src/guardrails/labels.d.ts +2 -2
  22. package/esm/src/guardrails/labels.js +7 -5
  23. package/esm/src/guardrails/mod.d.ts +2 -2
  24. package/esm/src/guardrails/mod.js +1 -1
  25. package/esm/src/guardrails/normalize.js +4 -2
  26. package/esm/src/guardrails/prompt-echo.js +4 -2
  27. package/esm/src/guardrails/sanitize.d.ts +1 -1
  28. package/esm/src/guardrails/sanitize.js +4 -3
  29. package/esm/src/guardrails/sensitive.d.ts +2 -2
  30. package/esm/src/guardrails/sensitive.js +1 -1
  31. package/esm/src/host/client-turn.js +8 -1
  32. package/esm/src/interface/profile-interface.js +13 -0
  33. package/esm/src/kernel/answer-tool.d.ts +14 -0
  34. package/esm/src/kernel/answer-tool.js +26 -0
  35. package/esm/src/kernel/engine/decision-references.d.ts +14 -0
  36. package/esm/src/kernel/engine/decision-references.js +55 -0
  37. package/esm/src/kernel/engine/decision.d.ts +3 -3
  38. package/esm/src/kernel/engine/decision.js +38 -22
  39. package/esm/src/kernel/engine/embedding.js +0 -2
  40. package/esm/src/kernel/engine/runner/gates.js +40 -5
  41. package/esm/src/kernel/engine/runner/mod.js +13 -5
  42. package/esm/src/kernel/engine/runner/state.d.ts +9 -1
  43. package/esm/src/kernel/engine/runner/state.js +10 -1
  44. package/esm/src/kernel/engine/runner/steps.js +50 -13
  45. package/esm/src/kernel/engine/runner/stream.d.ts +14 -8
  46. package/esm/src/kernel/engine/runner/stream.js +25 -13
  47. package/esm/src/kernel/engine/turn-trace.js +2 -2
  48. package/esm/src/kernel/mod.d.ts +1 -1
  49. package/esm/src/kernel/mod.js +1 -1
  50. package/esm/src/kernel/profile-presence.js +12 -0
  51. package/esm/src/kernel/profile-scope.js +14 -14
  52. package/esm/src/kernel/protocols/binding.d.ts +3 -1
  53. package/esm/src/kernel/protocols/binding.js +6 -0
  54. package/esm/src/kernel/protocols/limits.d.ts +10 -11
  55. package/esm/src/kernel/protocols/limits.js +9 -23
  56. package/esm/src/kernel/protocols/runtime.d.ts +3 -2
  57. package/esm/src/kernel/protocols/runtime.js +3 -4
  58. package/esm/src/kernel/provider-budget.d.ts +2 -2
  59. package/esm/src/kernel/provider-budget.js +22 -12
  60. package/esm/src/kernel/provider-runtime.js +8 -5
  61. package/esm/src/kernel/registry/profiles.js +3 -3
  62. package/esm/src/kernel/registry/resolve.js +4 -0
  63. package/esm/src/kernel/registry/schemas.js +1 -1
  64. package/esm/src/kernel/schema.js +28 -2
  65. package/esm/src/kernel/tools/execute.js +3 -1
  66. package/esm/src/kernel/tools/image-fetch.d.ts +2 -4
  67. package/esm/src/kernel/tools/image-fetch.js +19 -20
  68. package/esm/src/kernel/tools/mcp-sessions.d.ts +7 -2
  69. package/esm/src/kernel/tools/mcp-sessions.js +21 -12
  70. package/esm/src/kernel/tools/registry.js +21 -0
  71. package/esm/src/kernel/tools/remote.d.ts +0 -2
  72. package/esm/src/kernel/tools/remote.js +28 -23
  73. package/esm/src/kernel/tools/types.d.ts +30 -4
  74. package/esm/src/kernel/turn-events.d.ts +2 -0
  75. package/esm/src/kernel/turn-events.js +1 -0
  76. package/esm/src/kernel/types.d.ts +4 -2
  77. package/esm/src/observability/spans.js +7 -8
  78. package/esm/src/observability/trace-catalog.js +1 -0
  79. package/esm/src/providers/embedding-limits.js +4 -4
  80. package/esm/src/providers/gemini/{embedding-protocol.d.ts → embeddings-protocol.d.ts} +3 -3
  81. package/esm/src/providers/gemini/{embedding-protocol.js → embeddings-protocol.js} +3 -3
  82. package/esm/src/providers/gemini/live-protocol.js +4 -1
  83. package/esm/src/providers/gemini/provider.js +2 -2
  84. package/esm/src/providers/local/embeddings-protocol.d.ts +7 -0
  85. package/esm/src/providers/local/{embedding-protocol.js → embeddings-protocol.js} +2 -2
  86. package/esm/src/providers/local/provider.js +2 -2
  87. package/esm/src/providers/mod.d.ts +3 -3
  88. package/esm/src/providers/mod.js +3 -3
  89. package/esm/src/providers/openrouter/decisions-protocol.js +11 -8
  90. package/esm/src/providers/openrouter/embeddings-protocol.d.ts +6 -0
  91. package/esm/src/providers/openrouter/{embedding-protocol.js → embeddings-protocol.js} +2 -2
  92. package/esm/src/providers/openrouter/provider.js +2 -2
  93. package/package.json +1 -1
  94. package/esm/src/providers/local/embedding-protocol.d.ts +0 -7
  95. package/esm/src/providers/openrouter/embedding-protocol.d.ts +0 -6
@@ -5,7 +5,8 @@ import { guardedCall } from '../provider-runtime.js';
5
5
  import { soleModelId } from '../registry/sole-model.js';
6
6
  import { isRecord } from '../util/record.js';
7
7
  import { tracedCall } from './call-trace.js';
8
- /** Where the provider takes decision requests, as its preset states. */
8
+ import { referencedAssets } from './decision-references.js';
9
+ /** Each decision failure code, and the error kind it reads as. */
9
10
  const DECISION_ERROR_KINDS = {
10
11
  invalid_request: 'request',
11
12
  authentication: 'auth',
@@ -65,7 +66,8 @@ function distribution(value, labels, path) {
65
66
  for (const label of labels)
66
67
  out[label] = finite(value[label], `${path}.${label}`);
67
68
  const total = Object.values(out).reduce((sum, item) => sum + item, 0);
68
- if (Math.abs(total - 1) > 0.001) {
69
+ // why: Answers come rounded to hundredths, so each label may be off by half of one.
70
+ if (Math.abs(total - 1) > 0.005 * labels.length + 1e-9) {
69
71
  throw new DecisionError('malformed_response', `${path} must sum to one`); // labels-exempt: upstream contract error
70
72
  }
71
73
  return out;
@@ -310,19 +312,19 @@ function isDecisionEntry(value) {
310
312
  }
311
313
  return true;
312
314
  }
313
- async function enforceDisclosure(profile, model, provider, request) {
315
+ async function enforceDisclosure(profile, model, provider, request, assets) {
314
316
  const verdict = await profile.guardrails?.disclosure?.enforce(request.state, {
315
317
  destination: provider,
316
318
  profileId: profile.id,
317
319
  model,
318
320
  questionIds: Object.keys(request.questions),
319
- ...(request.assets ? { assets: request.assets } : {}),
321
+ ...(assets ? { assets } : {}),
320
322
  });
321
323
  if (verdict?.action === 'block') {
322
324
  throw new DecisionError('disclosure_blocked', 'Decision disclosure was blocked'); // labels-exempt: developer contract error
323
325
  }
324
326
  }
325
- function decisionSpanAttributes(profile, modelId, binding, request) {
327
+ function decisionSpanAttributes(profile, modelId, binding, request, assets) {
326
328
  return {
327
329
  'gen_ai.operation.name': 'decide',
328
330
  'gen_ai.provider.name': binding.provider,
@@ -332,10 +334,10 @@ function decisionSpanAttributes(profile, modelId, binding, request) {
332
334
  'theorem.decision.contract': profile.decision.contract,
333
335
  'theorem.decision.state': traceJson(request.state),
334
336
  'theorem.decision.questions': traceJson(request.questions),
335
- ...(request.assets
337
+ ...(assets
336
338
  ? {
337
339
  // invariant: Image bytes never reach a trace; it keeps each image's type and size.
338
- 'theorem.decision.assets': traceJson(Object.fromEntries(Object.entries(request.assets).map(([reference, { mimeType, data }]) => [
340
+ 'theorem.decision.assets': traceJson(Object.fromEntries(Object.entries(assets).map(([reference, { mimeType, data }]) => [
339
341
  reference,
340
342
  { mimeType, bytes: data.length },
341
343
  ]))),
@@ -384,25 +386,38 @@ function decisionProviderError(error) {
384
386
  // why: A framework failure's message holds no response body, and says what stopped the decision.
385
387
  error instanceof TheoremError ? error.message : labelText('provider.decision_failed'), isRecord(error) && typeof error.status === 'number' ? error.status : undefined, { cause: error });
386
388
  }
387
- async function decide(registry, profile, [modelId, binding], request, options) {
389
+ /** What the provider gets: each box's image said in its instructions, and only the images a string references. */
390
+ function sentCall(request) {
391
+ const questions = Object.fromEntries(Object.entries(request.questions).map(([id, question]) => [
392
+ id,
393
+ providerQuestion(question, request.assets),
394
+ ]));
395
+ const assets = request.assets
396
+ ? referencedAssets([
397
+ request.state,
398
+ ...Object.values(questions).flatMap((question) => [
399
+ question.instructions,
400
+ 'criteria' in question ? question.criteria : undefined,
401
+ ]),
402
+ ], request.assets)
403
+ : {};
404
+ return {
405
+ state: request.state,
406
+ questions,
407
+ ...(Object.keys(assets).length ? { assets } : {}),
408
+ };
409
+ }
410
+ async function decide(registry, profile, [modelId, binding], request, sent, options) {
388
411
  throwIfAborted(request.signal);
389
412
  const provider = registry.providers.require(binding.provider, binding.apiId);
390
- await enforceDisclosure(profile, modelId, binding.provider, request);
413
+ await enforceDisclosure(profile, modelId, binding.provider, request, sent.assets);
391
414
  throwIfAborted(request.signal);
392
415
  try {
393
416
  const call = guardedCall(provider, binding, 'decision', options, {
394
417
  ...(request.signal ? { signal: request.signal } : {}),
395
418
  ...(binding.timeoutMs ? { timeoutMs: binding.timeoutMs } : {}),
396
419
  });
397
- const result = await call.decide({
398
- apiId: binding.apiId,
399
- state: request.state,
400
- questions: Object.fromEntries(Object.entries(request.questions).map(([id, question]) => [
401
- id,
402
- providerQuestion(question, request.assets),
403
- ])),
404
- ...(request.assets ? { assets: request.assets } : {}),
405
- });
420
+ const result = await call.decide({ apiId: binding.apiId, ...sent });
406
421
  if (typeof result.model !== 'string' || !result.model.trim())
407
422
  throw new DecisionError('malformed_response', labelText('provider.decision_model_missing'));
408
423
  return { ...result, answers: validateAnswers(result.answers, request.questions) };
@@ -414,17 +429,18 @@ async function decide(registry, profile, [modelId, binding], request, options) {
414
429
  }
415
430
  }
416
431
  /**
417
- * Exactly one decision request, traced however it ends. An ambiguous POST is never retried,
418
- * and a trace write failure never changes the decision's outcome.
432
+ * One decision, traced however it ends. A transient failure (408, 429, a 5xx gateway or a network
433
+ * error) is retried within five seconds; a trace write failure never changes the decision's outcome.
419
434
  */
420
435
  export async function runDecisionInRegistry(registry, request, options) {
421
436
  const profile = requireDecisionProfile(registry, request.profile);
422
437
  validateDecisionRequest(request, profile);
423
438
  const model = decisionModel(profile);
424
439
  const [modelId, binding] = model;
440
+ const sent = sentCall(request);
425
441
  return await tracedCall({
426
442
  name: `decide ${binding.apiId}`,
427
- attributes: decisionSpanAttributes(profile, modelId, binding, request),
443
+ attributes: decisionSpanAttributes(profile, modelId, binding, request, sent.assets),
428
444
  traceparent: request.traceparent,
429
445
  metadata: request.metadata,
430
446
  writer: {
@@ -433,7 +449,7 @@ export async function runDecisionInRegistry(registry, request, options) {
433
449
  guardrails: profile.guardrails,
434
450
  },
435
451
  }, async (root) => {
436
- const result = await decide(registry, profile, model, request, options);
452
+ const result = await decide(registry, profile, model, request, sent, options);
437
453
  recordResult(root, result);
438
454
  return result;
439
455
  });
@@ -1,4 +1,3 @@
1
- import { refuseOverlong } from '../../guardrails/detect-at.js';
2
1
  import { TheoremError, throwIfAborted } from '../../guardrails/error.js';
3
2
  import { protocolOf } from '../protocols/binding.js';
4
3
  import { guardedCall } from '../provider-runtime.js';
@@ -47,7 +46,6 @@ function validateInputs(profile, modelId, inputs, model) {
47
46
  const files = [];
48
47
  for (const part of parts) {
49
48
  if (part.type === 'text') {
50
- refuseOverlong(part.text, 'embedding input');
51
49
  if (!rules.text)
52
50
  throw new TheoremError('unsupported', `Profile ${profile.id} does not accept text embedding input`);
53
51
  continue;
@@ -8,6 +8,7 @@ import { labelText } from '../../../guardrails/labels.js';
8
8
  import { resolveGuardrailPolicy } from '../../../guardrails/policy.js';
9
9
  import { replyIsJudged } from '../../../guardrails/progressive-yield.js';
10
10
  import { sanitizeTurnRequest } from '../../../guardrails/sanitize.js';
11
+ import { ANSWER_TOOL, answersByTool } from '../../answer-tool.js';
11
12
  import { resolveInputParts } from '../../registry/ingress.js';
12
13
  import { profileTurnOutputs } from '../../registry/profile-outputs.js';
13
14
  import { injectWouldExceedMaxSteps } from '../../stages.js';
@@ -15,7 +16,7 @@ import { findLast } from '../../util/find-last.js';
15
16
  import { guardrailCheckAttributes } from '../turn-trace.js';
16
17
  import { collectItemFailures, collectValidationFailures, formatValidationFailures, itemSchemaAt, valueAt, withListAt, } from './schema-validation.js';
17
18
  import { applyTurnStage } from './stages.js';
18
- import { appendUserInput, } from './state.js';
19
+ import { appendUserInput, recordStepEvent, } from './state.js';
19
20
  import { executeAttempt } from './steps.js';
20
21
  import { isWithheldOnBlock, shownToHost } from './stream.js';
21
22
  /** A turn whose final output the egress check blocked (withheld or replaced by policy copy). */
@@ -152,7 +153,8 @@ function updateFlowForRetry(flow, state, profile, nextReq, reason) {
152
153
  flow.currentReq = nextReq;
153
154
  const safe = sanitizeTurnRequest(nextReq, profile);
154
155
  if (profile.type === 'text') {
155
- // why: The conversation is already in turn history: the repair is its next user message.
156
+ // why: The repair is the turn's next user message, and the retry reads this turn alone.
157
+ state.turnOnly = true;
156
158
  const before = state.currentHistory.length;
157
159
  appendUserInput(state, resolveInputParts(profile, { ...safe, input: { repair: safe.input?.repair } }));
158
160
  for (const message of state.currentHistory.slice(before))
@@ -386,6 +388,31 @@ async function handleValidationGate(validation, flow, state, profile, latestStru
386
388
  holdReply(state, withoutItems(reply, refused), refused, outcome.failed);
387
389
  return 'pass';
388
390
  }
391
+ /**
392
+ * An attempt on a turn that answers by tool, ended without calling the answer tool: a repair while
393
+ * `outputs.validation` has a retry left, else a `bad_response` failure.
394
+ */
395
+ function* handleMissingAnswer(flow, state, profile, latestStructured,
396
+ /** True when the attempt's events go out at its end (`yieldBufferedAttemptEvents`). */
397
+ held) {
398
+ if (latestStructured !== undefined || state.lastStop?.kind !== 'completed')
399
+ return 'pass';
400
+ if (!answersByTool(flow.currentGen))
401
+ return 'pass';
402
+ const validation = profileTurnOutputs(profile)?.validation;
403
+ if (validation && flow.currentAttempt < (validation.maxRetries ?? 0)) {
404
+ const rejection = labelText('structured.answer_missing', { tool: ANSWER_TOOL }, profile.labels);
405
+ const nextRequest = buildRepairRequest(flow.currentReq, collectAttemptText(state.attemptEvents), rejection);
406
+ yield updateFlowForRetry(flow, state, profile, nextRequest, 'validation');
407
+ return 'continue';
408
+ }
409
+ const failure = toErrorEvent(new TheoremError('bad_response', `the model ended without calling ${ANSWER_TOOL}`));
410
+ recordStepEvent(failure, state);
411
+ state.lastStop = { kind: 'provider_error' };
412
+ if (!held)
413
+ yield failure;
414
+ return 'pass';
415
+ }
389
416
  /** Puts on the attempt's structured output the fields that still failed. */
390
417
  function markFailed(state, failed) {
391
418
  const held = findLast(state.attemptEvents, (event) => event.type === 'structured');
@@ -403,9 +430,6 @@ function gateStatusToAction(status) {
403
430
  }
404
431
  async function* executeSingleAttemptCycle(args) {
405
432
  const { flow, state, profile, system, provider } = args;
406
- const validation = profileTurnOutputs(profile)?.validation;
407
- const policy = resolveGuardrailPolicy(profile.guardrails);
408
- const judged = replyIsJudged(policy, TURN_REPLY);
409
433
  // invariant: Fresh maxSteps budget per validation/egress attempt. before_end inject
410
434
  // re-entry inside this cycle still accumulates stepCount (do not reset there).
411
435
  state.stepCount = 0;
@@ -465,6 +489,14 @@ async function* executeSingleAttemptCycle(args) {
465
489
  }
466
490
  break;
467
491
  }
492
+ return yield* checkAttemptReply(flow, state, profile, system, latestStructured);
493
+ }
494
+ /** The checks on an attempt's finished reply: the egress judge, the answer, then validation. */
495
+ async function* checkAttemptReply(flow, state, profile, system, structured) {
496
+ const validation = profileTurnOutputs(profile)?.validation;
497
+ const policy = resolveGuardrailPolicy(profile.guardrails);
498
+ const judged = replyIsJudged(policy, TURN_REPLY);
499
+ let latestStructured = structured;
468
500
  if (judged) {
469
501
  const status = yield* handleEgressGate(policy, flow, state, profile, system.private);
470
502
  const action = gateStatusToAction(status);
@@ -473,6 +505,9 @@ async function* executeSingleAttemptCycle(args) {
473
505
  }
474
506
  latestStructured = projectOutbound(state.attemptEvents).structured ?? latestStructured;
475
507
  }
508
+ if ((yield* handleMissingAnswer(flow, state, profile, latestStructured, Boolean(validation) || judged)) === 'continue') {
509
+ return { status: 'continue' };
510
+ }
476
511
  if (validation) {
477
512
  const status = await handleValidationGate(validation, flow, state, profile, latestStructured);
478
513
  if (status !== 'pass') {
@@ -1,5 +1,5 @@
1
1
  import { canaryNote, contextNote, profileCanary, userDataNote, } from '../../../guardrails/canary.js';
2
- import { isAbortError, TheoremError, throwIfAborted, withPublicLabels, } from '../../../guardrails/error.js';
2
+ import { describeError, errorKind, isAbortError, TheoremError, throwIfAborted, withPublicLabels, } from '../../../guardrails/error.js';
3
3
  import { projectGuardrailTurnEvent } from '../../../guardrails/events.js';
4
4
  import { labelText } from '../../../guardrails/labels.js';
5
5
  import { sanitizeTurnRequestWithEvents } from '../../../guardrails/sanitize.js';
@@ -22,7 +22,7 @@ import { sumEventTokens, sumTurnTokens } from '../usage.js';
22
22
  import { runAttemptsWithValidation } from './gates.js';
23
23
  import { createAgentCaller, NESTED_THROWS, turnError } from './nested.js';
24
24
  import { applyTurnStage } from './stages.js';
25
- import { openTurnState } from './state.js';
25
+ import { cutConversation, openTurnState, } from './state.js';
26
26
  import { shouldSkipStreamEvent } from './stream.js';
27
27
  function projectForObs(event, policy) {
28
28
  return projectGuardrailTurnEvent(event, policy?.include.guardrailMatchPreview ?? false);
@@ -105,10 +105,10 @@ async function runCompactor(args) {
105
105
  }
106
106
  catch (err) {
107
107
  throwIfAborted(args.signal);
108
- const error = err instanceof TheoremError ? err.kind : 'internal';
108
+ const error = errorKind(err);
109
109
  if (NESTED_THROWS.has(error))
110
110
  throw err;
111
- return { droppedMedia, failure: { error } };
111
+ return { droppedMedia, failure: { error, errorInternal: describeError(err) } };
112
112
  }
113
113
  throwIfAborted(args.signal);
114
114
  const tokens = sumEventTokens(events);
@@ -122,7 +122,11 @@ async function runCompactor(args) {
122
122
  if (stop !== 'completed' || error) {
123
123
  return {
124
124
  droppedMedia,
125
- failure: { ...(stop ? { stop } : {}), ...(error ? { error } : {}) },
125
+ failure: {
126
+ ...(stop ? { stop } : {}),
127
+ ...(error ? { error } : {}),
128
+ ...(reported?.errorInternal ? { errorInternal: reported.errorInternal } : {}),
129
+ },
126
130
  ...usage,
127
131
  };
128
132
  }
@@ -163,6 +167,9 @@ function compactionOutcome(result, messagesBefore) {
163
167
  dropped_media: result.droppedMedia,
164
168
  ...(result.failure?.stop ? { failure_stop: result.failure.stop } : {}),
165
169
  ...(result.failure?.error ? { failure_error: result.failure.error } : {}),
170
+ ...(result.failure?.errorInternal
171
+ ? { failure_reason: traceContent(result.failure.errorInternal) }
172
+ : {}),
166
173
  ...(result.failure?.empty ? { failure_empty: true } : {}),
167
174
  ...(result.failure?.unreadable ? { failure_unreadable: true } : {}),
168
175
  ...(result.summary === undefined ? {} : { summary: traceContent(result.summary) }),
@@ -274,6 +281,7 @@ async function* emitTurn(args) {
274
281
  const done = turnDoneOf({
275
282
  stop: state.lastStop ?? { kind: 'completed' },
276
283
  ...(state.providerState &&
284
+ !cutConversation(state) &&
277
285
  !('providerContinuation' in profile && profile.providerContinuation?.history === 'stateless') &&
278
286
  (!state.lastStop || ['completed', 'tool', 'gate'].includes(state.lastStop.kind))
279
287
  ? { providerState: state.providerState }
@@ -23,6 +23,10 @@ interface StepExecutionState {
23
23
  agents?: AgentCaller;
24
24
  trace: TurnTraceState;
25
25
  currentHistory: TurnHistoryMessage[];
26
+ /** Where this turn begins in `currentHistory`: what came before is the conversation the host passed. */
27
+ turnStart: number;
28
+ /** Set by a retry: from then on the model reads this turn only, from `turnStart`. */
29
+ turnOnly?: boolean;
26
30
  stepCount: number;
27
31
  /** Model calls sent again this turn under the profile's resumption (`executeResumedStep`). */
28
32
  resumes: number;
@@ -106,5 +110,9 @@ declare function openTurnState(args: {
106
110
  allEmittedEvents?: TurnEvent[];
107
111
  agents?: AgentCaller;
108
112
  }): StepExecutionState;
113
+ /** The history the next model call reads: this turn alone once a retry has cut it. */
114
+ declare function modelHistory(state: StepExecutionState): TurnHistoryMessage[];
115
+ /** Whether the turn's checkpoint covers less than the conversation the host keeps. */
116
+ declare function cutConversation(state: StepExecutionState): boolean;
109
117
  export type { AttemptFlowState, PendingItemFix, StepExecutionState, TurnTraceState };
110
- export { appendUserInput, openTurnState, recordStepEvent };
118
+ export { appendUserInput, cutConversation, modelHistory, openTurnState, recordStepEvent };
@@ -28,6 +28,7 @@ function openTurnState(args) {
28
28
  tools: args.tools,
29
29
  trace: args.trace,
30
30
  currentHistory: [...(generation.history ?? [])],
31
+ turnStart: generation.history?.length ?? 0,
31
32
  stepCount: 0,
32
33
  resumes: 0,
33
34
  toolResults: [],
@@ -49,4 +50,12 @@ function openTurnState(args) {
49
50
  }
50
51
  return state;
51
52
  }
52
- export { appendUserInput, openTurnState, recordStepEvent };
53
+ /** The history the next model call reads: this turn alone once a retry has cut it. */
54
+ function modelHistory(state) {
55
+ return state.turnOnly ? state.currentHistory.slice(state.turnStart) : state.currentHistory;
56
+ }
57
+ /** Whether the turn's checkpoint covers less than the conversation the host keeps. */
58
+ function cutConversation(state) {
59
+ return state.turnOnly === true && state.turnStart > 0;
60
+ }
61
+ export { appendUserInput, cutConversation, modelHistory, openTurnState, recordStepEvent };
@@ -7,6 +7,7 @@ import { resolveGuardrailPolicy } from '../../../guardrails/policy.js';
7
7
  import { replyIsJudged } from '../../../guardrails/progressive-yield.js';
8
8
  import { recordTaint } from '../../../guardrails/tool-result.js';
9
9
  import { waitDefault } from '../../../providers/shared/retry.js';
10
+ import { ANSWER_TOOL, answersByTool, withAnswerTool } from '../../answer-tool.js';
10
11
  import { stepReplyMessages } from '../../interaction-parts.js';
11
12
  import { profileTurnOutputs } from '../../registry/profile-outputs.js';
12
13
  import { providerCompleteRequest } from '../../registry/provider-request.js';
@@ -20,7 +21,7 @@ import { findLast } from '../../util/find-last.js';
20
21
  import { startToolTrace } from '../tool-trace.js';
21
22
  import { startCallTrace } from '../turn-trace.js';
22
23
  import { applyStageInjects, recordReply } from './stages.js';
23
- import { recordStepEvent } from './state.js';
24
+ import { modelHistory, recordStepEvent } from './state.js';
24
25
  import { inStep, runSideBySide, stepOrder } from './step-order.js';
25
26
  import { isWithheldOnBlock, StructuredDrafts, yieldProviderEvents, } from './stream.js';
26
27
  import { callTokensEvent, observeCallEvent, startCallUsage } from './usage.js';
@@ -29,7 +30,7 @@ function isStepLimitReached(step, maxSteps) {
29
30
  }
30
31
  /** The step's generation: the turn's history so far, and no tools when the step must answer. */
31
32
  function generationForProviderStep(generation, state, answerOnly) {
32
- const forStep = { ...generation, history: state.currentHistory };
33
+ const forStep = { ...generation, history: modelHistory(state) };
33
34
  if (!answerOnly)
34
35
  return forStep;
35
36
  return { ...forStep, builtins: [], tools: { ...generation.tools, wire: [] } };
@@ -60,14 +61,14 @@ function holdModelCall(calls, tool) {
60
61
  return true;
61
62
  }
62
63
  /** The step's request, with what it gives the model noted on `state`, and its call's usage and trace. */
63
- function startProviderCall(generation, system, state, answerOnly) {
64
+ function startProviderCall(generation, system, state, answerOnly, labels) {
64
65
  const usage = startCallUsage(system.text, {
65
- history: state.currentHistory,
66
+ history: modelHistory(state),
66
67
  input: generation.input,
67
68
  });
68
69
  state.lastCall = usage;
69
70
  const genForStep = generationForProviderStep(generation, state, answerOnly);
70
- const request = providerCompleteRequest(state.tools, genForStep, system.text);
71
+ const request = withAnswerTool(providerCompleteRequest(state.tools, genForStep, system.text), generation, labels);
71
72
  addRequestUrls(state.givenUrls, request);
72
73
  addRequestDestinations(state.destinations, request, state.givenUrls.own);
73
74
  if (generation.canary && !state.canaryGiven) {
@@ -118,6 +119,42 @@ function tallyCallEvent(event, tally, call) {
118
119
  tally.replied = true;
119
120
  return false;
120
121
  }
122
+ /**
123
+ * The structured output the call gave through the answer tool, shown as the step's, with the answer
124
+ * tool's calls taken out of the step's calls. An answer ends the turn, so the step's other calls do
125
+ * not run, and the call that gave it completed.
126
+ */
127
+ async function* showAnswer(shown, tally, step) {
128
+ if (!answersByTool(step.generation))
129
+ return;
130
+ const answer = tally.calls.find((call) => call.name === ANSWER_TOOL && !call.failure);
131
+ tally.calls = answer ? [] : tally.calls.filter((call) => call.name !== ANSWER_TOOL);
132
+ if (!answer)
133
+ return;
134
+ if (tally.stop?.kind === 'tool') {
135
+ tally.stop = { kind: 'completed' };
136
+ step.state.lastStop = tally.stop;
137
+ }
138
+ const event = { type: 'structured', structured: answer.arguments };
139
+ if (tallyCallEvent(event, tally, step.call))
140
+ return;
141
+ if (releasesNow(event, step.control, step.state, step.buffer))
142
+ yield* shown.show(event);
143
+ }
144
+ /**
145
+ * A tool call's preview. The answer tool's streams as the structured reply's draft.
146
+ *
147
+ * why: A preview of the call that follows, which owns the arguments: not the turn's record. It streams
148
+ * as the reply's text does, through its own gate, and like that text is held only when delivery is buffered.
149
+ */
150
+ async function* showToolDraft(event, shown, generation, buffer) {
151
+ if (buffer.holdVisible)
152
+ return;
153
+ if (event.name === ANSWER_TOOL && answersByTool(generation))
154
+ yield* shown.draft(event.text);
155
+ else
156
+ yield event;
157
+ }
121
158
  /** What sets one model call's text apart from the reply text before it: a blank line. */
122
159
  const CALL_BREAK = '\n\n';
123
160
  /**
@@ -130,7 +167,8 @@ function withCallBreak(event, tally, state, generation) {
130
167
  if (tally.text)
131
168
  return event;
132
169
  const before = state.replyText;
133
- const breaks = before && generation.structured === null && !/\s$/.test(before) && !/^\s/.test(event.text);
170
+ const prose = generation.structured === null || answersByTool(generation);
171
+ const breaks = before && prose && !/\s$/.test(before) && !/^\s/.test(event.text);
134
172
  tally.replyAt = before.length + (breaks ? CALL_BREAK.length : 0);
135
173
  return breaks ? { ...event, text: `${CALL_BREAK}${event.text}` } : event;
136
174
  }
@@ -194,7 +232,7 @@ async function* executeAutonomousStep(args, state, buffer = {
194
232
  holdVisible: false,
195
233
  }) {
196
234
  const { profile, generation, system, provider, signal } = args;
197
- const { usage, genForStep, request, call } = startProviderCall(generation, system, state, isAnswerOnlyStep(profile, generation, state.stepCount));
235
+ const { usage, genForStep, request, call } = startProviderCall(generation, system, state, isAnswerOnlyStep(profile, generation, state.stepCount), profile.labels);
198
236
  const tally = { calls: [], reasoning: [], text: '', failures: [], replied: false };
199
237
  const control = {
200
238
  withholdVisible: false,
@@ -209,7 +247,7 @@ async function* executeAutonomousStep(args, state, buffer = {
209
247
  canaryGiven: state.canaryGiven,
210
248
  ...(state.ownTools ? { ownTools: state.ownTools } : {}),
211
249
  };
212
- const shown = new StructuredDrafts(seen);
250
+ const shown = new StructuredDrafts({ ...seen, generation });
213
251
  try {
214
252
  for await (const event of yieldProviderEvents({
215
253
  ...seen,
@@ -219,6 +257,7 @@ async function* executeAutonomousStep(args, state, buffer = {
219
257
  signal,
220
258
  control,
221
259
  tools: state.tools,
260
+ answerTool: answersByTool(generation),
222
261
  })) {
223
262
  if (observeCallEvent(usage, event))
224
263
  continue;
@@ -234,14 +273,11 @@ async function* executeAutonomousStep(args, state, buffer = {
234
273
  continue;
235
274
  }
236
275
  if (event.type === 'tool_draft') {
237
- // why: A preview of the call that follows, which owns the arguments: not the turn's record. It streams
238
- // as the reply's text does, through its own gate, and like that text is held only when delivery is buffered.
239
- if (!buffer.holdVisible)
240
- yield event;
276
+ yield* showToolDraft(event, shown, generation, buffer);
241
277
  continue;
242
278
  }
243
279
  // why: The model's history keeps what it wrote; only the reply the host gets is set apart.
244
- const next = event.type === 'text' ? withCallBreak(event, tally, state, genForStep) : event;
280
+ const next = event.type === 'text' ? withCallBreak(event, tally, state, generation) : event;
245
281
  if (tallyCallEvent(event, tally, call))
246
282
  continue;
247
283
  if (!releasesNow(next, control, state, buffer))
@@ -254,6 +290,7 @@ async function* executeAutonomousStep(args, state, buffer = {
254
290
  throw err;
255
291
  }
256
292
  yield* shown.end();
293
+ yield* showAnswer(shown, tally, { generation, state, control, buffer, call });
257
294
  keepStreamCarry(state, control);
258
295
  const resume = resumeOf(args.mayResume, profile, tally);
259
296
  if (!resume) {
@@ -24,18 +24,20 @@ interface OutboundStreamControl {
24
24
  declare function shouldSkipStreamEvent(event: ModelEvent, profile: Profile): boolean;
25
25
  /** Host-visible output a mid-stream block withholds. A thought is never withheld, only omitted from. */
26
26
  declare function isWithheldOnBlock(event: ModelEvent): boolean;
27
+ export type ShownGeneration = Pick<ResolvedGeneration, 'structured' | 'stream' | 'tools'>;
27
28
  /**
28
- * An event as the host gets it, or nothing. The text of a call that asked for structured output is
29
- * that output's JSON: under `sse` it goes out as `structured_draft`, and under `buffered` not at all,
30
- * since the `structured` event is the reply whole. The turn keeps it as text, which its checks,
31
- * repairs and history read.
29
+ * An event as the host gets it, or nothing. On a turn that answers through a response format, the
30
+ * text is that output's JSON: under `sse` it goes out as `structured_draft`, and under `buffered`
31
+ * not at all, since the `structured` event is the reply whole. The turn keeps it as text, which its
32
+ * checks, repairs and history read. On a turn that answers by tool, the text is progress.
32
33
  */
33
- declare function shownToHost(event: TurnEvent, generation: Pick<ResolvedGeneration, 'structured' | 'stream'>): TurnEvent | undefined;
34
+ declare function shownToHost(event: TurnEvent, generation: ShownGeneration): TurnEvent | undefined;
34
35
  /**
35
36
  * A step's events as the host gets them (`shownToHost`), with a structured reply's drafts read
36
- * first at `reply_structured`, where the whole is read. The reply's text has already passed the
37
- * `reply` gate; a match at `reply_structured` stops the drafts, and the `structured` event still
38
- * arrives as that check leaves it. The held tail is read and released when the step's stream ends.
37
+ * first at `reply_structured`, where the whole is read. The drafts are the reply's text, or the
38
+ * answer tool's arguments as the model writes them. Text has already passed the `reply` gate; a
39
+ * match at `reply_structured` stops the drafts, and the `structured` event still arrives as that
40
+ * check leaves it. The held tail is read and released when the step's stream ends.
39
41
  */
40
42
  declare class StructuredDrafts {
41
43
  private readonly drafts;
@@ -43,6 +45,8 @@ declare class StructuredDrafts {
43
45
  /** Reads the stream's own checks off what the step knows of the turn. */
44
46
  constructor(seen: Pick<StreamArgs, 'profile' | 'generation' | 'privateSystem' | 'givenUrls' | 'canaryGiven' | 'ownTools'>);
45
47
  show(event: TurnEvent): AsyncGenerator<TurnEvent>;
48
+ /** Part of the structured output's JSON, as the model writes it. */
49
+ draft(json: string): AsyncGenerator<TurnEvent>;
46
50
  /** The drafts' tail the gate still holds, once the step's stream has ended. */
47
51
  end(): Generator<TurnEvent>;
48
52
  }
@@ -65,6 +69,8 @@ interface StreamArgs {
65
69
  ownTools?: OwnTools;
66
70
  /** The turn's tools: a draft is read at the boundary its tool's calls are. */
67
71
  tools: ToolRegistry;
72
+ /** True when the turn answers through the answer tool (`answersByTool`). */
73
+ answerTool?: boolean;
68
74
  }
69
75
  declare function yieldProviderEvents(args: StreamArgs): AsyncGenerator<StreamEvent>;
70
76
  export type { OutboundStreamControl, StreamArgs };
@@ -7,6 +7,7 @@ import { resolveGuardrailPolicy } from '../../../guardrails/policy.js';
7
7
  import { createDraftGate, createOutboundProgressiveGate, DraftReader, } from '../../../guardrails/progressive-yield.js';
8
8
  import { EGRESS_RULES } from '../../../guardrails/rules.js';
9
9
  import { thoughtGuardFor, } from '../../../guardrails/thought-guard.js';
10
+ import { ANSWER_TOOL, answersByTool } from '../../answer-tool.js';
10
11
  import { profileTurnOutputs } from '../../registry/profile-outputs.js';
11
12
  import { toolArgumentsBoundary } from '../../tools/execute.js';
12
13
  function shouldSkipStreamEvent(event, profile) {
@@ -69,21 +70,22 @@ function isWithheldOnBlock(event) {
69
70
  return event.type === 'text' || event.type === 'media';
70
71
  }
71
72
  /**
72
- * An event as the host gets it, or nothing. The text of a call that asked for structured output is
73
- * that output's JSON: under `sse` it goes out as `structured_draft`, and under `buffered` not at all,
74
- * since the `structured` event is the reply whole. The turn keeps it as text, which its checks,
75
- * repairs and history read.
73
+ * An event as the host gets it, or nothing. On a turn that answers through a response format, the
74
+ * text is that output's JSON: under `sse` it goes out as `structured_draft`, and under `buffered`
75
+ * not at all, since the `structured` event is the reply whole. The turn keeps it as text, which its
76
+ * checks, repairs and history read. On a turn that answers by tool, the text is progress.
76
77
  */
77
78
  function shownToHost(event, generation) {
78
- if (!generation.structured || event.type !== 'text')
79
+ if (!generation.structured || answersByTool(generation) || event.type !== 'text')
79
80
  return event;
80
81
  return generation.stream === false ? undefined : { type: 'structured_draft', text: event.text };
81
82
  }
82
83
  /**
83
84
  * A step's events as the host gets them (`shownToHost`), with a structured reply's drafts read
84
- * first at `reply_structured`, where the whole is read. The reply's text has already passed the
85
- * `reply` gate; a match at `reply_structured` stops the drafts, and the `structured` event still
86
- * arrives as that check leaves it. The held tail is read and released when the step's stream ends.
85
+ * first at `reply_structured`, where the whole is read. The drafts are the reply's text, or the
86
+ * answer tool's arguments as the model writes them. Text has already passed the `reply` gate; a
87
+ * match at `reply_structured` stops the drafts, and the `structured` event still arrives as that
88
+ * check leaves it. The held tail is read and released when the step's stream ends.
87
89
  */
88
90
  class StructuredDrafts {
89
91
  drafts;
@@ -102,9 +104,15 @@ class StructuredDrafts {
102
104
  yield shown;
103
105
  return;
104
106
  }
105
- const text = await this.drafts.read(shown.text);
107
+ yield* this.draft(shown.text);
108
+ }
109
+ /** Part of the structured output's JSON, as the model writes it. */
110
+ async *draft(json) {
111
+ if (this.generation.stream === false)
112
+ return;
113
+ const text = await this.drafts.read(json);
106
114
  if (text)
107
- yield { ...shown, text };
115
+ yield { type: 'structured_draft', text };
108
116
  }
109
117
  /** The drafts' tail the gate still holds, once the step's stream has ended. */
110
118
  *end() {
@@ -131,11 +139,13 @@ class ToolDrafts {
131
139
  policy;
132
140
  context;
133
141
  tools;
142
+ answerTool;
134
143
  drafts = new Map();
135
- constructor(policy, context, tools) {
144
+ constructor(policy, context, tools, answerTool) {
136
145
  this.policy = policy;
137
146
  this.context = context;
138
147
  this.tools = tools;
148
+ this.answerTool = answerTool;
139
149
  }
140
150
  /**
141
151
  * Shows `event` when it is a draft and says whether it was; any other event closes its call's
@@ -178,7 +188,9 @@ class ToolDrafts {
178
188
  this.drafts.delete(event.tool.callId);
179
189
  }
180
190
  async open(event) {
181
- const boundary = toolArgumentsBoundary(this.tools, event.name);
191
+ // why: The answer's drafts are the structured output's, read at `reply_structured` by `StructuredDrafts`.
192
+ const answer = this.answerTool && event.name === ANSWER_TOOL;
193
+ const boundary = answer ? undefined : toolArgumentsBoundary(this.tools, event.name);
182
194
  const gate = boundary ? await createDraftGate(this.policy, this.context, boundary) : null;
183
195
  const draft = { name: event.name, gate };
184
196
  this.drafts.set(event.callId, draft);
@@ -216,7 +228,7 @@ async function* yieldProviderEvents(args) {
216
228
  const { canaryCarry, thoughtCarry } = control ?? {};
217
229
  const gate = await createOutboundProgressiveGate(policy, context, 'reply', canaryCarry);
218
230
  const thoughts = await thoughtGuardFor(policy, context, thoughtCarry);
219
- const drafts = new ToolDrafts(policy, context, args.tools);
231
+ const drafts = new ToolDrafts(policy, context, args.tools, args.answerTool === true);
220
232
  /** The streamed event whose reply sits in the gate's lookback; released tails keep its shape. */
221
233
  let pendingStream = null;
222
234
  let withholdVisible = false;
@@ -1,4 +1,4 @@
1
- import { errorKind } from '../../guardrails/error.js';
1
+ import { describeError, errorKind } from '../../guardrails/error.js';
2
2
  import { traceBytes, traceContent, traceJson, } from '../../observability/trace-span.js';
3
3
  import { historyMessageParts } from '../interaction-parts.js';
4
4
  import { isFailedStopKind } from '../stop.js';
@@ -597,7 +597,7 @@ function errorName(err) {
597
597
  function recordException(span, err) {
598
598
  span.event('exception', {
599
599
  'exception.type': errorName(err),
600
- 'exception.message': traceContent(err instanceof Error ? err.message : String(err)),
600
+ 'exception.message': traceContent(describeError(err)),
601
601
  ...(err instanceof Error && err.stack
602
602
  ? { 'exception.stacktrace': traceContent(err.stack) }
603
603
  : {}),