Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/harness/src/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down
75 changes: 75 additions & 0 deletions packages/harness/src/stop-reason-message.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
64 changes: 64 additions & 0 deletions packages/harness/src/stop-reason-message.ts
Original file line number Diff line number Diff line change
@@ -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.";
}
}
Loading