can1357/oh-my-pi · error

Soft tool requirement

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.

Solutions

  1. Check that the required tool name matches a registered tool exactly and its schema is simple enough for the model to satisfy
  2. Inspect the session transcript: the preceding turns contain 'skipped' tool results explaining what the model must call — fix whatever the model keeps doing instead
  3. Remove or reconsider the soft tool requirement if it is not actually mandatory for the task, or raise MAX_SOFT_TOOL_ESCALATIONS
  4. 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

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


AI-assisted analysis of can1357/oh-my-pi@9690622007 (2026-08-31). Data as JSON: /api/errors/3a29641bedaaac7d. Report an issue: GitHub.

Appendix: 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)