can1357/oh-my-pi · error
Soft tool requirement '${softRequiredTool}' was not satisfie
Error message
Soft tool requirement '${softRequiredTool}' was not satisfied after ${MAX_SOFT_TOOL_ESCALATIONS} forced turns; aborting to avoid an unbounded force loop. What it means
When a session declares a soft-required tool (a tool the model MUST call, e.g. to resolve a pending user action), the loop pairs any non-compliant model turn with skipped tool results and forces the required tool via toolChoice on the next turn. After MAX_SOFT_TOOL_ESCALATIONS (3) forced turns the model still has not complied, so the loop throws to prevent an infinite force loop burning tokens.
Source
Thrown at packages/agent/src/agent-loop.ts:1404
hasMoreToolCalls = false;
}
// A turn is compliant ONLY when it calls the required tool and nothing
// else — mirroring the forced-tool_choice turn, which can emit only that
// tool. A required+detour batch is treated as non-compliant so detour
// tools never run side effects while the requirement is still pending.
const calledOnlyRequiredTool =
softRequiredTool !== undefined &&
toolCalls.length > 0 &&
toolCalls.every(toolCall => softSatisfies?.(toolCall) ?? toolCall.name === softRequiredTool);
const softGateActive =
softRequiredTool !== undefined && !hardToolChoiceBlocks(config.toolChoice, softRequiredTool);
const softNonCompliant = softGateActive && !calledOnlyRequiredTool;
const toolResults: ToolResultMessage[] = [];
if (softNonCompliant && softRequiredTool !== undefined) {
if (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {
throw new Error(
`Soft tool requirement '${softRequiredTool}' was not satisfied after ${MAX_SOFT_TOOL_ESCALATIONS} forced turns; aborting to avoid an unbounded force loop.`,
);
}
// A soft-required tool is pending but the model called something else
// (or yielded). Do NOT execute the detour — pair each call with a
// skipped result and force the required tool next turn. This is the
// only turn that changes toolChoice; a model that complies with the
// reminder pays no message-cache invalidation. Re-engage so the loop
// never yields while the requirement is unmet.
for (const toolCall of toolCalls) {
const result = createAbortedToolResult(
toolCall,
stream,
"skipped",
`Not executed: call the \`${softRequiredTool}\` tool to resolve the pending action before using other tools.`,
);
currentContext.messages.push(result);
newMessages.push(result);View on GitHub (pinned to 9690622007)
Solutions
- Check that the required tool name matches a registered tool exactly and its schema is simple enough for the model to satisfy
- Inspect the session transcript: the preceding turns contain 'skipped' tool results explaining what the model must call — fix whatever the model keeps doing instead
- Remove or reconsider the soft tool requirement if it is not actually mandatory for the task, or raise MAX_SOFT_TOOL_ESCALATIONS
- Use a stronger model that reliably honors forced tool_choice, or switch the requirement to a hard toolChoice so the provider enforces it server-side
Example fix
// before: model keeps calling 'read_file' when 'apply_patch' is soft-required
// -> throws after 3 forced turns
// after: enforce it hard so the provider guarantees the call
// before
const agent = new Agent({ toolChoice: "auto" /* soft requirement forced manually */ });
// after
const agent = new Agent({ toolChoice: { type: "tool", name: "apply_patch" } }); Defensive patterns
Strategy: try-catch
Validate before calling
// Before enabling a soft requirement, confirm the tool exists and is callable
const required = "apply_patch";
const registered = agent.getTools().some(t => t.name === required);
if (!registered) throw new Error(`soft-required tool '${required}' is not registered`); Type guard
function isSoftToolEscalationError(err: unknown): err is Error {
return err instanceof Error && err.message.startsWith("Soft tool requirement '") && err.message.includes("forced turns");
} Try / catch
try {
await agent.prompt(input);
} catch (err) {
if (isSoftToolEscalationError(err)) {
// read transcript 'skipped' tool results to see what the model did instead,
// then either drop the requirement or escalate to a hard toolChoice
logger.warn("model never satisfied soft requirement", { tool: err.message });
} else throw err;
} Prevention
- Verify the soft-required tool name exactly matches a registered tool
- Keep required-tool schemas simple; confusing schemas cause detour calls
- Prefer hard toolChoice ({ type: "tool", name }) when the call is truly mandatory — the provider enforces it server-side
- Use models known to honor forced tool_choice reliably; weak models burn all 3 escalations
When it happens
Trigger: A soft tool requirement is active (set via the soft-required-tools/dialect gating mechanism) and for 3 consecutive escalated turns the model either yields text, calls no tools, or calls other tools instead of the required one — each detour is skipped and forced, and on the 4th non-compliance the error is thrown.
Common situations: A weak or quantized model repeatedly ignoring forced tool_choice; the required tool's schema confusing the model so it calls a similar tool instead; a tool name mismatch between the requirement and the registered tool list; hard toolChoice config that blocks the forced tool (softGateActive checks hardToolChoiceBlocks).
Related errors
- Tool ${toolCall.name} not found
- Tool "${toolCall.name}" not found
- Validation failed for tool "${toolCall.name}": Tool call arg
- Validation failed for tool "${toolCall.name}":\n${errors}\n\
- Cannot continue: no messages in context
AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31).
Data as JSON: /api/errors/3a29641bedaaac7d.
Report an issue: GitHub.