feat(ai): tool-error recovery middleware

Pattern-matches known-recoverable MCP tool failures and injects a
synthetic imperative message into the conversation right after the
failing tool result. Static prompt rules lose to accumulated tool
reality (we've shipped 4 orphan twenty-* services because the model
ignored the "no delete-and-recreate" rule); a fresh role:'user'
message at decision time does not.

Initial rules cover the three highest-confidence Docker failure
patterns: orphan container conflict (use apps_unstick), image pull
denied (use apps_repair), port already allocated (identify holder).
Each rule names the wrong-but-tempting move explicitly.

See AI_HARNESS_GAPS.md §1 for the failure case this addresses.
This commit is contained in:
2026-05-01 11:08:48 -07:00
parent f7fdc34af1
commit c105b42d0c
2 changed files with 130 additions and 0 deletions

View File

@@ -19,6 +19,7 @@ import { authSession } from '@/lib/auth/session-server';
import { query } from '@/lib/db-postgres';
import { callGeminiChat } from '@/lib/ai/gemini-chat';
import { VIBN_TOOL_DEFINITIONS, executeMcpTool } from '@/lib/ai/vibn-tools';
import { detectKnownError, formatRecoveryMessage } from '@/lib/ai/error-recovery';
import type { ChatMessage, ToolCall } from '@/lib/ai/gemini-chat';
// Bumped from 6 to 12 because Path B chains (devcontainer.ensure →
@@ -457,6 +458,24 @@ export async function POST(request: Request) {
toolName: tc.name,
thoughtSignature: tc.thoughtSignature,
});
// Harness-layer error recovery: if the tool result matches
// a known-recoverable failure (e.g. orphan container
// conflict), inject a synthetic user-role message
// immediately after the tool result. This puts a fresh
// imperative ("CALL apps_unstick. DO NOT delete-and-
// recreate.") in the conversation right where the model
// is about to decide what to do next. Static prompt
// rules lose to accumulated tool reality; an injected
// message at decision time does not. See
// lib/ai/error-recovery.ts.
const recovery = detectKnownError(result);
if (recovery) {
messages.push({
role: 'user',
content: formatRecoveryMessage(recovery),
});
}
}
if (loopBreakReason) break;

111
lib/ai/error-recovery.ts Normal file
View File

@@ -0,0 +1,111 @@
/**
* Tool-error recovery middleware.
*
* Pattern-matches known-recoverable error strings in MCP tool results
* and produces a synthetic system message instructing the model on the
* exact recovery action. Injected into the conversation before the
* next model round.
*
* Why this exists (vs just a system-prompt rule):
* Static prompt rules against accumulating tool reality lose. We've
* shipped 4 orphan twenty-* services because the model kept doing
* delete-and-recreate even though the prompt told it not to. The
* model treats prompt rules as soft guidance; it cannot ignore a
* fresh `role: "system"` message that arrives between tool result
* and next call. See AI_HARNESS_GAPS.md §1 for the full case.
*
* Adding a rule:
* 1. Pick a regex that matches the error string with NO false
* positives. If it could fire on a legitimate success or
* unrelated failure, leave it out — silent miss > wrong fix.
* 2. Write the `diagnosis` as a sentence the model can use as-is
* in a status update to the user.
* 3. Write `requiredAction` as the literal next tool call(s) the
* model should make, with arg shapes if non-obvious.
* 4. Write `antipattern` as the wrong-but-tempting move the model
* keeps doing. The injected message tells it explicitly NOT
* to do this.
*
* Rules are checked in registration order. First match wins.
*/
export interface RecoveryRule {
/** Stable identifier for logs / future telemetry. */
id: string;
/** Pattern that uniquely identifies this error in tool output. */
pattern: RegExp;
/** Human-readable explanation of what went wrong. */
diagnosis: string;
/** Exact next tool call(s) the model should make. */
requiredAction: string;
/** The wrong move the model keeps making for this error. */
antipattern: string;
}
const RULES: RecoveryRule[] = [
{
id: 'orphan-container-conflict',
// Matches: `Conflict. The container name "/postgres-..." is already in use`
// Real prod example, twenty-crm thread, 2026-04-30.
pattern: /Conflict\.\s+The container name\s+["/]?[\w./-]+["/]?\s+is already in use/i,
diagnosis:
'A previous deploy left an orphan Docker container holding this service\'s container name. The new boot is colliding with the orphan. This is a recoverable state.',
requiredAction:
'Call `apps_unstick { uuid }` against the SAME app uuid you were just trying to deploy, then `apps_deploy { uuid }`. Both calls use the existing uuid; do not create a new app.',
antipattern:
'Do NOT delete the failing app and create a new one with a different name. That keeps the orphan running, doubles the stack, and ships another shadow service. We have shipped 4 orphan twenty-* services this way before. Do not repeat it.',
},
{
id: 'image-pull-denied',
// Matches: `pull access denied for ...` and `manifest unknown` from the registry.
pattern: /(pull access denied for|manifest unknown|repository does not exist)/i,
diagnosis:
'The Docker image referenced by this app is not on the host, and the registry pull failed (private repo, missing credentials, or wrong tag).',
requiredAction:
'Call `apps_repair { uuid }` to re-attempt the post-deploy fixes. If that fails too, surface the exact image reference to the user and ask whether the image should be pulled from a different registry or rebuilt.',
antipattern:
'Do NOT retry the same `apps_deploy` blindly hoping the registry will respond differently. The pull failure is persistent until the underlying image-availability issue is fixed.',
},
{
id: 'port-already-allocated',
// Matches: `port is already allocated` / `bind: address already in use`.
pattern: /(port\s+\S+\s+is already allocated|bind:\s+address already in use|Ports are not available)/i,
diagnosis:
'A different container or process on the host is already bound to the port this app is trying to claim.',
requiredAction:
'Use `apps_containers_list { uuid }` plus `shell_exec` (e.g. `docker ps --filter publish=<port>`) to identify the holder. If the holder is a stale Coolify-managed container, call `apps_unstick { uuid }` on its app. If it is a legitimate other app, surface the conflict to the user and ask which one should get the port.',
antipattern:
'Do NOT pick a random different port and retry. Port choice is part of the user\'s product configuration; a silent change will break their docs / DNS / clients.',
},
];
/**
* Inspect a tool result and return the matching recovery rule, or
* null if nothing matches. The result is treated as plain text;
* structured JSON tool results work fine because the error strings
* we match on appear inside the JSON value.
*/
export function detectKnownError(toolResult: unknown): RecoveryRule | null {
if (toolResult == null) return null;
const text = typeof toolResult === 'string' ? toolResult : JSON.stringify(toolResult);
for (const rule of RULES) {
if (rule.pattern.test(text)) return rule;
}
return null;
}
/**
* Format a recovery rule as the synthetic system message we inject
* into the conversation before the next model round. The shape is
* deliberately imperative ("CALL X. DO NOT do Y.") because that is
* the prompting style the model responds to most reliably.
*/
export function formatRecoveryMessage(rule: RecoveryRule): string {
return [
`[RECOVERY: ${rule.id}]`,
`Diagnosis: ${rule.diagnosis}`,
`Required next action: ${rule.requiredAction}`,
`Do NOT: ${rule.antipattern}`,
`Send the user a one-line status before the recovery call so they know what you are doing.`,
].join('\n');
}