{"record":{"id":"3a29641bedaaac7d","repo":"can1357/oh-my-pi","slug":"soft-tool-requirement-softrequiredtool-was-no","errorCode":null,"errorMessage":"Soft tool requirement '${softRequiredTool}' was not satisfied after ${MAX_SOFT_TOOL_ESCALATIONS} forced turns; aborting to avoid an unbounded force loop.","messagePattern":"Soft tool requirement '(.+?)' was not satisfied after (.+?) forced turns; aborting to avoid an unbounded force loop\\.","errorType":"exception","errorClass":null,"httpStatus":null,"severity":"error","filePath":"packages/agent/src/agent-loop.ts","lineNumber":1404,"sourceCode":"\t\t\t\t\thasMoreToolCalls = false;\n\t\t\t\t}\n\n\t\t\t\t// A turn is compliant ONLY when it calls the required tool and nothing\n\t\t\t\t// else — mirroring the forced-tool_choice turn, which can emit only that\n\t\t\t\t// tool. A required+detour batch is treated as non-compliant so detour\n\t\t\t\t// tools never run side effects while the requirement is still pending.\n\t\t\t\tconst calledOnlyRequiredTool =\n\t\t\t\t\tsoftRequiredTool !== undefined &&\n\t\t\t\t\ttoolCalls.length > 0 &&\n\t\t\t\t\ttoolCalls.every(toolCall => softSatisfies?.(toolCall) ?? toolCall.name === softRequiredTool);\n\t\t\t\tconst softGateActive =\n\t\t\t\t\tsoftRequiredTool !== undefined && !hardToolChoiceBlocks(config.toolChoice, softRequiredTool);\n\t\t\t\tconst softNonCompliant = softGateActive && !calledOnlyRequiredTool;\n\n\t\t\t\tconst toolResults: ToolResultMessage[] = [];\n\t\t\t\tif (softNonCompliant && softRequiredTool !== undefined) {\n\t\t\t\t\tif (softRequirementState.escalations >= MAX_SOFT_TOOL_ESCALATIONS) {\n\t\t\t\t\t\tthrow new Error(\n\t\t\t\t\t\t\t`Soft tool requirement '${softRequiredTool}' was not satisfied after ${MAX_SOFT_TOOL_ESCALATIONS} forced turns; aborting to avoid an unbounded force loop.`,\n\t\t\t\t\t\t);\n\t\t\t\t\t}\n\t\t\t\t\t// A soft-required tool is pending but the model called something else\n\t\t\t\t\t// (or yielded). Do NOT execute the detour — pair each call with a\n\t\t\t\t\t// skipped result and force the required tool next turn. This is the\n\t\t\t\t\t// only turn that changes toolChoice; a model that complies with the\n\t\t\t\t\t// reminder pays no message-cache invalidation. Re-engage so the loop\n\t\t\t\t\t// never yields while the requirement is unmet.\n\t\t\t\t\tfor (const toolCall of toolCalls) {\n\t\t\t\t\t\tconst result = createAbortedToolResult(\n\t\t\t\t\t\t\ttoolCall,\n\t\t\t\t\t\t\tstream,\n\t\t\t\t\t\t\t\"skipped\",\n\t\t\t\t\t\t\t`Not executed: call the \\`${softRequiredTool}\\` tool to resolve the pending action before using other tools.`,\n\t\t\t\t\t\t);\n\t\t\t\t\t\tcurrentContext.messages.push(result);\n\t\t\t\t\t\tnewMessages.push(result);","sourceCodeStart":1386,"sourceCodeEnd":1422,"githubUrl":"https://github.com/can1357/oh-my-pi/blob/969062200754ea02cfac922e5ebb8c608c079e15/packages/agent/src/agent-loop.ts#L1386-L1422","documentation":"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.","triggerScenarios":"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.","commonSituations":"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).","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"],"exampleFix":"// before: model keeps calling 'read_file' when 'apply_patch' is soft-required\n// -> throws after 3 forced turns\n// after: enforce it hard so the provider guarantees the call\n// before\nconst agent = new Agent({ toolChoice: \"auto\" /* soft requirement forced manually */ });\n// after\nconst agent = new Agent({ toolChoice: { type: \"tool\", name: \"apply_patch\" } });","handlingStrategy":"try-catch","validationCode":"// Before enabling a soft requirement, confirm the tool exists and is callable\nconst required = \"apply_patch\";\nconst registered = agent.getTools().some(t => t.name === required);\nif (!registered) throw new Error(`soft-required tool '${required}' is not registered`);","typeGuard":"function isSoftToolEscalationError(err: unknown): err is Error {\n  return err instanceof Error && err.message.startsWith(\"Soft tool requirement '\") && err.message.includes(\"forced turns\");\n}","tryCatchPattern":"try {\n  await agent.prompt(input);\n} catch (err) {\n  if (isSoftToolEscalationError(err)) {\n    // read transcript 'skipped' tool results to see what the model did instead,\n    // then either drop the requirement or escalate to a hard toolChoice\n    logger.warn(\"model never satisfied soft requirement\", { tool: err.message });\n  } else throw err;\n}","preventionTips":["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"],"tags":["llm","tool-calling","agent-loop","force-loop","non-compliance"],"backgroundTag":"forced-tool-noncompliance","analyzedSha":"969062200754ea02cfac922e5ebb8c608c079e15","analyzedAt":"2026-08-31T10:29:35.737Z","schemaVersion":2},"datasetVersion":"2026-08-31T14:17:45.589Z"}