From 0af075a5e2876d38cb994acdc2e0072bd096b95c Mon Sep 17 00:00:00 2001 From: kjgbot Date: Sat, 25 Apr 2026 18:39:37 +0200 Subject: [PATCH] feat(harness): export stopReasonToUserMessage so consumers never silently drop turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a tiny helper that maps every documented HarnessStopReason to a non-empty, end-user-facing message. Exported from @agent-assistant/harness so sage's slack-runner, cloud's specialist-worker fallback, and any future surface share one voice instead of each shipping its own ad-hoc table — the silent-failure footgun was when a consumer forgot a stop reason and posted an empty reply. Includes an optional `canRetry` switch so callers with a queued fallback path (e.g. sage's harness→swarm retry) can produce "retrying with a fallback" wording instead of dead-ending the user. Tests cover: - every documented stop reason returns a non-empty string - undefined / empty / unknown values fall through to a generic message - canRetry flips retryable reasons to retry-language - copy is distinct per reason (with one allowed collision for the max_iterations / max_tool_calls pair, which share UX intentionally) --- packages/harness/src/index.ts | 2 + .../harness/src/stop-reason-message.test.ts | 75 +++++++++++++++++++ packages/harness/src/stop-reason-message.ts | 64 ++++++++++++++++ 3 files changed, 141 insertions(+) create mode 100644 packages/harness/src/stop-reason-message.test.ts create mode 100644 packages/harness/src/stop-reason-message.ts diff --git a/packages/harness/src/index.ts b/packages/harness/src/index.ts index 9683be9..776e156 100644 --- a/packages/harness/src/index.ts +++ b/packages/harness/src/index.ts @@ -1,5 +1,7 @@ export { createHarness } from './harness.js'; export { HarnessConfigError } from './types.js'; +export { stopReasonToUserMessage } from './stop-reason-message.js'; +export type { StopReasonMessageOptions } from './stop-reason-message.js'; export * from './adapter/index.js'; export { OpenRouterModelAdapter, createOpenRouterModelAdapter } from './adapter/openrouter-model-adapter.js'; diff --git a/packages/harness/src/stop-reason-message.test.ts b/packages/harness/src/stop-reason-message.test.ts new file mode 100644 index 0000000..3e4f6cc --- /dev/null +++ b/packages/harness/src/stop-reason-message.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; + +import { stopReasonToUserMessage } from './stop-reason-message.js'; +import type { HarnessStopReason } from './types.js'; + +const ALL_STOP_REASONS: HarnessStopReason[] = [ + 'answer_finalized', + 'clarification_required', + 'approval_required', + 'max_iterations_reached', + 'max_tool_calls_reached', + 'timeout_reached', + 'budget_reached', + 'tool_unavailable', + 'tool_error_unrecoverable', + 'model_refused', + 'model_invalid_response', + 'runtime_error', + 'cancelled', +]; + +describe('stopReasonToUserMessage', () => { + it('returns a non-empty string for every documented stop reason', () => { + for (const reason of ALL_STOP_REASONS) { + const message = stopReasonToUserMessage(reason); + expect(message, `stop reason: ${reason}`).toBeTruthy(); + expect(message.length, `stop reason: ${reason}`).toBeGreaterThan(0); + } + }); + + it('returns a non-empty fallback for undefined, empty, or unknown stop reasons', () => { + expect(stopReasonToUserMessage(undefined)).toBeTruthy(); + expect(stopReasonToUserMessage('')).toBeTruthy(); + expect(stopReasonToUserMessage('something_we_did_not_define_yet')).toBeTruthy(); + }); + + it('mentions a retry when canRetry is true for retryable stop reasons', () => { + const retryable: HarnessStopReason[] = [ + 'max_iterations_reached', + 'max_tool_calls_reached', + 'timeout_reached', + 'model_invalid_response', + ]; + for (const reason of retryable) { + const message = stopReasonToUserMessage(reason, { canRetry: true }); + expect(message.toLowerCase(), `retryable reason: ${reason}`).toContain('retry'); + } + }); + + it('does not promise a retry when canRetry is omitted or false', () => { + const message = stopReasonToUserMessage('max_iterations_reached'); + expect(message.toLowerCase()).not.toContain('retrying'); + const messageFalse = stopReasonToUserMessage('max_iterations_reached', { canRetry: false }); + expect(messageFalse.toLowerCase()).not.toContain('retrying'); + }); + + it('uses distinct, meaningful copy for each documented reason', () => { + const messages = new Set(ALL_STOP_REASONS.map((reason) => stopReasonToUserMessage(reason))); + // We deliberately collapse max_iterations_reached + max_tool_calls_reached + // onto the same copy (same UX), so allow up to one collision. + expect(messages.size).toBeGreaterThanOrEqual(ALL_STOP_REASONS.length - 1); + }); + + it('asks for clarification on clarification_required', () => { + expect(stopReasonToUserMessage('clarification_required').toLowerCase()).toContain('clarif'); + }); + + it('signals refusal on model_refused', () => { + expect(stopReasonToUserMessage('model_refused').toLowerCase()).toContain("can't help"); + }); + + it('signals cancellation on cancelled', () => { + expect(stopReasonToUserMessage('cancelled').toLowerCase()).toContain('cancel'); + }); +}); diff --git a/packages/harness/src/stop-reason-message.ts b/packages/harness/src/stop-reason-message.ts new file mode 100644 index 0000000..066d236 --- /dev/null +++ b/packages/harness/src/stop-reason-message.ts @@ -0,0 +1,64 @@ +import type { HarnessStopReason } from './types.js'; + +export interface StopReasonMessageOptions { + /** + * Whether the caller has a retry path queued (e.g. sage's harness→swarm + * fallback). Affects retryable-stop-reason wording so the message tells + * the user a retry is in progress rather than dead-ending them. + */ + canRetry?: boolean; +} + +/** + * Maps a harness stop reason to a one-line, end-user-facing message. + * + * Returns a non-empty string for every documented `HarnessStopReason`, + * plus a generic fallback for unknown / empty values. Use as the + * user-visible reply when a turn ends without a usable assistant message + * (outcome !== 'completed', or completed with empty text). Centralizing + * the strings here keeps every consumer (sage's slack-runner, cloud's + * specialist-worker fallback, CLI surfaces) speaking the same voice and + * removes the silent-failure footgun where a consumer forgets to map a + * stop reason and posts an empty reply. + */ +export function stopReasonToUserMessage( + stopReason: HarnessStopReason | string | undefined, + options: StopReasonMessageOptions = {}, +): string { + const canRetry = options.canRetry ?? false; + switch (stopReason) { + case 'answer_finalized': + return "I don't have anything more to add."; + case 'clarification_required': + return "I need more details to answer that — could you clarify what you're looking for?"; + case 'approval_required': + return "I need approval before I can finish that step."; + case 'max_iterations_reached': + case 'max_tool_calls_reached': + return canRetry + ? "I got stuck looping on tools and didn't reach an answer. Retrying with a simpler path." + : "I got stuck looping on tools and didn't reach an answer. Try rephrasing or breaking the request into smaller pieces."; + case 'timeout_reached': + return canRetry + ? "That took too long to gather. Retrying with a simpler path." + : "That took too long to gather — try a narrower question."; + case 'budget_reached': + return "I hit this turn's budget before finishing. Try a narrower question."; + case 'tool_unavailable': + return "A tool I needed wasn't available, so I couldn't complete that. Try again in a moment."; + case 'tool_error_unrecoverable': + return "A tool I called failed unrecoverably. Try again in a moment."; + case 'model_refused': + return "I can't help with that request."; + case 'model_invalid_response': + return canRetry + ? "I had trouble with my primary reply path. Retrying with a fallback." + : "I had trouble producing a usable reply. Try rephrasing the request."; + case 'runtime_error': + return 'Something went wrong while processing that. Try again in a moment.'; + case 'cancelled': + return 'Cancelled.'; + default: + return "I couldn't complete that request."; + } +}