mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 12:33:05 +01:00
## Thinking Path
> - Paperclip is the open source app people use to manage AI agents for
work
> - Native OpenCode runs turn the provider's last assistant text into
the task's visible reply
> - A model can loop until it reaches the output limit. The provider
then reports `finish: "length"`
> - The driver used that cut-off text as the final answer, and the run
succeeded with a broken reply
> - This pull request treats a response that stopped at the output limit
as incomplete
> - The benefit is that users get the accepted completion summary or one
corrected answer, never a truncated wall of text
## Linked Issues or Issue Description
**What happened?**
In a QA staging run, an OpenCode agent on
`openrouter/deepseek/deepseek-v4.1-flash-1017` called `paperclip_finish`
correctly. Then it produced about 32,000 output tokens of repeated text
(`E2E_QUIP_QUIP_...`) until the output limit stopped it. The driver
selected this truncated text as the settled final answer. The run
succeeded, and the task reply was wrong.
**Expected behavior**
A response that the provider stopped at the output limit is not a
complete answer. If an accepted completion report exists, its summary is
the reply. If not, the driver asks for one concise final answer. If that
also stops at the limit, the turn fails.
**Steps to reproduce**
1. Run a native OpenCode turn where the model calls `paperclip_finish`.
2. Let the next assistant message run to the output limit (OpenCode
reports `finish: "length"`).
3. Before this change, that message text is emitted as the final
`agentMessage`.
**Paperclip version or commit**
master at e8163fc860.
## What Changed
- `opencode-server-driver.ts`: record assistant messages that finish
with `length`. Emit a `harness.diagnostic` with code
`opencode_output_limit_reached`.
- Exclude text from these messages from final-answer selection and from
the empty-response check. An accepted completion summary then resolves
the reply (existing behavior when no final text exists).
- Without an accepted result, reuse the existing single correction
prompt. Its first sentence says that the previous response reached the
output limit and was discarded.
- If the correction also fails, the turn fails with
`opencode_output_limit_reached`, not `opencode_empty_response`.
- `native-provider-failure.ts`: add `opencode_output_limit_reached` to
the known OpenCode runner failure codes.
- Tests: new `output-limit.test.ts` (limit then answer, limit twice,
normal), and a driver test for a runaway reply after an accepted
`paperclip_finish`, with a new fake-server scenario.
## Verification
- `npx vitest run src/drivers/opencode` in `packages/paperclip-runner`:
13 files, 166 tests passed.
- Negative controls: with the old driver, the two new limit cases and
the post-completion case fail.
- `native-provider-failure.test.ts`: 57 tests passed.
- The live QA staging journey is not verified yet. That needs a staging
deploy of this change.
## Risks
- A long but valid answer that ends exactly at the output limit is now
discarded and gets one correction request. The provider itself marks
such an answer as cut off, so it was already incomplete.
- When pre-tool commentary exists and the only post-tool text is
truncated, existing selection rules can pick that commentary. This is
the same as the existing behavior when no post-tool text exists.
- Only the OpenCode driver changes. Other drivers are not affected.
## Model Used
- Claude Opus 5.5 (`claude-opus-5-5`), Anthropic, via Claude Code with
tool use (shell, browser, code editing). Extended reasoning was used.
## Checklist
- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
285 lines
14 KiB
TypeScript
285 lines
14 KiB
TypeScript
import type { PrpEvent, PrpTerminalState } from "../../vendor/paperclip-runner/index.js";
|
|
import { readProviderFailure, type ProviderFailure } from "@paperclipai/shared";
|
|
|
|
export const NATIVE_MODEL_REJECTION_MESSAGE =
|
|
"The selected model is not supported by the current ChatGPT connection. Choose a supported model or a compatible AI connection.";
|
|
|
|
export const NATIVE_PROVIDER_OVERLOADED_MESSAGE =
|
|
"Selected model is at capacity. Please try a different model.";
|
|
export const NATIVE_PROVIDER_OVERLOADED_CODE = "native_provider_overloaded";
|
|
export const NATIVE_PROVIDER_CAPACITY_MAX_RETRIES = 2;
|
|
export const NATIVE_PROVIDER_OVERLOADED_DIAGNOSTIC = {
|
|
provider: "codex",
|
|
category: "server_overloaded",
|
|
} as const;
|
|
|
|
export const NATIVE_MODEL_REJECTION_DIAGNOSTIC = {
|
|
provider: "codex",
|
|
category: "model_auth_incompatible",
|
|
status: 400,
|
|
authMode: "chatgpt",
|
|
} as const;
|
|
|
|
function record(value: unknown): Record<string, unknown> {
|
|
return value !== null && typeof value === "object" && !Array.isArray(value)
|
|
? value as Record<string, unknown>
|
|
: {};
|
|
}
|
|
|
|
/** Reconstruct only the closed diagnostic vocabulary, never provider text. */
|
|
export function readNativeModelRejectionDiagnostic(value: unknown) {
|
|
try {
|
|
const entry = record(value);
|
|
return Object.entries(NATIVE_MODEL_REJECTION_DIAGNOSTIC).every(([key, expected]) => entry[key] === expected)
|
|
? { ...NATIVE_MODEL_REJECTION_DIAGNOSTIC }
|
|
: null;
|
|
} catch { return null; }
|
|
}
|
|
|
|
/**
|
|
* Codex `CodexErrorInfo` variants. Unit variants arrive as strings; HTTP
|
|
* variants arrive as one-key objects that can carry `httpStatusCode`.
|
|
*/
|
|
const CODEX_ERROR_INFO_KINDS: ReadonlySet<string> = new Set([
|
|
"contextWindowExceeded", "usageLimitExceeded", "serverOverloaded", "httpConnectionFailed",
|
|
"responseStreamConnectionFailed", "internalServerError", "unauthorized", "badRequest",
|
|
"threadRollbackFailed", "sandboxError", "responseStreamDisconnected",
|
|
"responseTooManyFailedAttempts", "other",
|
|
]);
|
|
/**
|
|
* `error.code` values the runner itself writes on a failed Codex turn: the
|
|
* Codex driver's `failProtocol` callers and runnerd's Codex backend. A provider
|
|
* error object can also carry `code`, so any other value is `unrecognized`.
|
|
*/
|
|
const NATIVE_TURN_FAILURE_RUNNER_CODES: ReadonlySet<string> = new Set([
|
|
"thread_binding_mismatch", "turn_binding_mismatch", "turn_start_mismatch", "tool_binding_mismatch",
|
|
"runtime_request_binding_mismatch", "runtime_request_duplicate", "runtime_request_recovery_failed",
|
|
"notification_transport_failed", "provider_event_type_invalid", "invalid_semantic_result",
|
|
"conflicting_semantic_result", "conflicting_turn_terminal", "invalid_spawn_lineage",
|
|
"provider_descendant_capacity_exhausted", "provider_terminal_error_missing", "provider_turn_lost_on_restore",
|
|
]);
|
|
|
|
/**
|
|
* Closed vocabulary for a failed Codex turn that no classifier above claims.
|
|
* It explains generic `Native session failed` reports without provider text.
|
|
*/
|
|
export interface NativeCodexTurnFailureDiagnostic {
|
|
provider: "codex";
|
|
category: "turn_failed";
|
|
/** A known `CodexErrorInfo` kind, `unrecognized`, or `absent`. */
|
|
codexErrorInfo: string;
|
|
httpStatus?: number;
|
|
/** A known runner-issued failure code, or `unrecognized` for any other code. */
|
|
runnerFailureCode?: string;
|
|
}
|
|
|
|
/**
|
|
* `error.code` values the OpenCode driver writes when it fails a turn itself.
|
|
* Other OpenCode failures carry provider error objects and stay `unrecognized`.
|
|
*/
|
|
const OPENCODE_TURN_FAILURE_RUNNER_CODES: ReadonlySet<string> = new Set([
|
|
"opencode_server_exited", "opencode_event_stream_lost", "provider_terminal_error_missing",
|
|
"opencode_empty_response", "opencode_output_limit_reached",
|
|
]);
|
|
const PROVIDER_FAILURE_CATEGORIES: ReadonlySet<string> = new Set<ProviderFailure["category"]>([
|
|
"rate_limit", "quota_exhausted", "authentication", "permission", "model_unavailable", "invalid_request",
|
|
"overloaded", "timeout", "unknown",
|
|
]);
|
|
const PROVIDER_FAILURE_RETRYABILITY: ReadonlySet<string> = new Set<ProviderFailure["retryability"]>([
|
|
"transient", "requires_action", "unknown",
|
|
]);
|
|
const OPENCODE_STREAM_FAILURE_KINDS: ReadonlySet<string> = new Set([
|
|
"server_exited", "connection_failed", "http_error", "stream_closed", "stream_error",
|
|
]);
|
|
const OPENCODE_CONNECTION_ERRORS: ReadonlySet<string> = new Set([
|
|
"ECONNREFUSED", "ECONNRESET", "EPIPE", "ETIMEDOUT", "EHOSTUNREACH", "ENETUNREACH",
|
|
"UND_ERR_SOCKET", "UND_ERR_CLOSED", "UND_ERR_CONNECT_TIMEOUT", "UND_ERR_HEADERS_TIMEOUT",
|
|
"UND_ERR_BODY_TIMEOUT", "unrecognized",
|
|
]);
|
|
/** POSIX, Linux and macOS signal names; any other `signal` value is dropped. */
|
|
const OPENCODE_EXIT_SIGNALS: ReadonlySet<string> = new Set([
|
|
"SIGHUP", "SIGINT", "SIGQUIT", "SIGILL", "SIGTRAP", "SIGABRT", "SIGIOT", "SIGBUS", "SIGEMT", "SIGFPE",
|
|
"SIGKILL", "SIGUSR1", "SIGSEGV", "SIGUSR2", "SIGPIPE", "SIGALRM", "SIGTERM", "SIGSTKFLT", "SIGCHLD",
|
|
"SIGCONT", "SIGSTOP", "SIGTSTP", "SIGTTIN", "SIGTTOU", "SIGURG", "SIGXCPU", "SIGXFSZ", "SIGVTALRM",
|
|
"SIGPROF", "SIGWINCH", "SIGIO", "SIGPOLL", "SIGPWR", "SIGINFO", "SIGLOST", "SIGSYS", "SIGUNUSED",
|
|
]);
|
|
|
|
/** Closed vocabulary for a failed OpenCode turn, including a lost `opencode serve`. */
|
|
export interface NativeOpenCodeTurnFailureDiagnostic {
|
|
provider: "opencode";
|
|
category: "turn_failed";
|
|
/** A known runner-issued failure code, or `unrecognized` for any other code. */
|
|
runnerFailureCode?: string;
|
|
/** How the driver lost its event stream, when it failed the turn itself. */
|
|
streamFailure?: string;
|
|
exitCode?: number;
|
|
signal?: string;
|
|
connectionError?: string;
|
|
/** The stream failure's HTTP status, or else the provider error's status. */
|
|
httpStatus?: number;
|
|
/** The provider error's category and retryability, when the turn carried one. */
|
|
providerFailureCategory?: ProviderFailure["category"];
|
|
providerFailureRetryability?: ProviderFailure["retryability"];
|
|
}
|
|
|
|
export type NativeTurnFailureDiagnostic = NativeCodexTurnFailureDiagnostic | NativeOpenCodeTurnFailureDiagnostic;
|
|
|
|
function httpStatusField(value: unknown) {
|
|
return typeof value === "number" && Number.isInteger(value) && value >= 100 && value <= 599
|
|
? { httpStatus: value } : {};
|
|
}
|
|
|
|
function runnerFailureCodeField(value: unknown, codes: ReadonlySet<string> = NATIVE_TURN_FAILURE_RUNNER_CODES) {
|
|
if (typeof value !== "string") return {};
|
|
return { runnerFailureCode: codes.has(value) ? value : "unrecognized" };
|
|
}
|
|
|
|
function openCodeStreamFailureFields(value: unknown): Omit<NativeOpenCodeTurnFailureDiagnostic, "provider" | "category" | "runnerFailureCode"> {
|
|
const entry = record(value);
|
|
const kind = entry.kind ?? entry.streamFailure;
|
|
if (typeof kind !== "string" || !OPENCODE_STREAM_FAILURE_KINDS.has(kind)) return {};
|
|
const { exitCode, signal, connectionError } = entry;
|
|
return {
|
|
streamFailure: kind,
|
|
...(typeof exitCode === "number" && Number.isInteger(exitCode) && exitCode >= 0 && exitCode <= 255 ? { exitCode } : {}),
|
|
...(typeof signal === "string" && OPENCODE_EXIT_SIGNALS.has(signal) ? { signal } : {}),
|
|
...(typeof connectionError === "string" && OPENCODE_CONNECTION_ERRORS.has(connectionError) ? { connectionError } : {}),
|
|
...httpStatusField(entry.httpStatus),
|
|
};
|
|
}
|
|
|
|
/** Only the provider error's closed fields; its message and code stay out of diagnostics. */
|
|
function openCodeProviderFailureFields(
|
|
category: unknown, retryability: unknown, httpStatus: unknown,
|
|
): Pick<NativeOpenCodeTurnFailureDiagnostic, "providerFailureCategory" | "providerFailureRetryability" | "httpStatus"> {
|
|
if (typeof category !== "string" || !PROVIDER_FAILURE_CATEGORIES.has(category)) return {};
|
|
return {
|
|
providerFailureCategory: category as ProviderFailure["category"],
|
|
...(typeof retryability === "string" && PROVIDER_FAILURE_RETRYABILITY.has(retryability)
|
|
? { providerFailureRetryability: retryability as ProviderFailure["retryability"] } : {}),
|
|
...httpStatusField(httpStatus),
|
|
};
|
|
}
|
|
|
|
function openCodeTurnFailure(failure: Record<string, unknown>): NativeOpenCodeTurnFailureDiagnostic {
|
|
const providerFailure = readProviderFailure(failure.providerFailure);
|
|
return {
|
|
provider: "opencode",
|
|
category: "turn_failed",
|
|
...runnerFailureCodeField(failure.code, OPENCODE_TURN_FAILURE_RUNNER_CODES),
|
|
...(providerFailure ? openCodeProviderFailureFields(
|
|
providerFailure.category, providerFailure.retryability, providerFailure.httpStatus) : {}),
|
|
...openCodeStreamFailureFields(failure.opencodeFailure),
|
|
};
|
|
}
|
|
|
|
function codexErrorInfoFields(value: unknown): Pick<NativeCodexTurnFailureDiagnostic, "codexErrorInfo" | "httpStatus"> {
|
|
if (value === undefined || value === null) return { codexErrorInfo: "absent" };
|
|
if (typeof value === "string") return { codexErrorInfo: CODEX_ERROR_INFO_KINDS.has(value) ? value : "unrecognized" };
|
|
const entry = record(value);
|
|
const keys = Object.keys(entry);
|
|
if (keys.length !== 1 || !CODEX_ERROR_INFO_KINDS.has(keys[0]!)) return { codexErrorInfo: "unrecognized" };
|
|
return { codexErrorInfo: keys[0]!, ...httpStatusField(record(entry[keys[0]!]).httpStatusCode) };
|
|
}
|
|
|
|
/** Rebuild a persisted turn-failure diagnostic from its closed vocabulary only. */
|
|
export function readNativeTurnFailureDiagnostic(value: unknown): NativeTurnFailureDiagnostic | null {
|
|
try {
|
|
const entry = record(value);
|
|
if (entry.provider === "opencode") {
|
|
if (entry.category !== "turn_failed") return null;
|
|
return {
|
|
provider: "opencode",
|
|
category: "turn_failed",
|
|
...runnerFailureCodeField(entry.runnerFailureCode, OPENCODE_TURN_FAILURE_RUNNER_CODES),
|
|
...openCodeProviderFailureFields(entry.providerFailureCategory, entry.providerFailureRetryability, entry.httpStatus),
|
|
...openCodeStreamFailureFields(entry),
|
|
};
|
|
}
|
|
const kind = entry.codexErrorInfo;
|
|
if (entry.provider !== "codex" || entry.category !== "turn_failed" || typeof kind !== "string" ||
|
|
(kind !== "absent" && kind !== "unrecognized" && !CODEX_ERROR_INFO_KINDS.has(kind))) return null;
|
|
return {
|
|
provider: "codex",
|
|
category: "turn_failed",
|
|
codexErrorInfo: kind,
|
|
...httpStatusField(entry.httpStatus),
|
|
...runnerFailureCodeField(entry.runnerFailureCode),
|
|
};
|
|
} catch { return null; }
|
|
}
|
|
|
|
/** Observe only events accepted by the bound control-plane persistence port. */
|
|
export function createNativeProviderFailureObservation(provider: { kind: string; model?: string | null }) {
|
|
let rejectedTurnId: string | null = null;
|
|
let overloadedTurnId: string | null = null;
|
|
const failedTurns = new Map<string, NativeTurnFailureDiagnostic>();
|
|
const providerFailures = new Map<string, ProviderFailure>();
|
|
return {
|
|
observe(event: PrpEvent) {
|
|
if (event.sourceKind === "runner" && event.eventType === "turn.failed" && event.turnId
|
|
&& event.payload.status === "failed") {
|
|
const failure = readProviderFailure(record(event.payload.error).providerFailure);
|
|
if (failure && !providerFailures.has(event.turnId)) providerFailures.set(event.turnId, failure);
|
|
}
|
|
if ((provider.kind !== "codex" && provider.kind !== "opencode") ||
|
|
event.sourceKind !== "runner" || event.eventType !== "turn.failed" || !event.turnId) return;
|
|
const payload = record(event.payload);
|
|
const failure = record(payload.error);
|
|
if (provider.kind === "opencode") {
|
|
// OpenCode has no classifier above, so every failed turn is diagnosed.
|
|
if (payload.status === "failed") failedTurns.set(event.turnId, openCodeTurnFailure(failure));
|
|
return;
|
|
}
|
|
if (payload.status === "failed") {
|
|
failedTurns.set(event.turnId, {
|
|
provider: "codex",
|
|
category: "turn_failed",
|
|
...codexErrorInfoFields(failure.codexErrorInfo),
|
|
...runnerFailureCodeField(failure.code),
|
|
});
|
|
}
|
|
if (payload.status === "failed" && failure.codexErrorInfo === "serverOverloaded") {
|
|
overloadedTurnId = event.turnId;
|
|
return;
|
|
}
|
|
if (!provider.model || payload.status !== "failed" || payload.recoverable === true || failure.recoverable === true ||
|
|
typeof failure.message !== "string" || failure.message.length > 4_096) return;
|
|
let response: Record<string, unknown>;
|
|
try { response = record(JSON.parse(failure.message)); } catch { return; }
|
|
const error = record(response.error);
|
|
// This is a provider terminal envelope, not text emitted by a tool or an
|
|
// agent. Bind the exact rejection to the configured model without storing
|
|
// its name or the response. Unknown provider errors retain their behavior.
|
|
if (response.type !== "error" || response.status !== 400 || error.type !== "invalid_request_error" ||
|
|
error.message !== `The '${provider.model}' model is not supported when using Codex with a ChatGPT account.`) return;
|
|
rejectedTurnId = event.turnId;
|
|
},
|
|
providerFailureForTerminal(turnId: string | null | undefined, terminal: PrpTerminalState) {
|
|
if (!turnId || terminal.turnTerminalState !== "failed" || terminal.runTerminalState !== "failed") return null;
|
|
return providerFailures.get(turnId) ?? null;
|
|
},
|
|
forTerminal(turnId: string | null | undefined, terminal: PrpTerminalState) {
|
|
if (!turnId || terminal.turnTerminalState !== "failed" || terminal.runTerminalState !== "failed") return null;
|
|
if (turnId === overloadedTurnId) return {
|
|
errorCode: NATIVE_PROVIDER_OVERLOADED_CODE,
|
|
errorMessage: NATIVE_PROVIDER_OVERLOADED_MESSAGE,
|
|
diagnostic: { ...NATIVE_PROVIDER_OVERLOADED_DIAGNOSTIC },
|
|
};
|
|
if (turnId !== rejectedTurnId) return null;
|
|
return {
|
|
errorCode: "native_provider_model_rejected" as const,
|
|
errorMessage: NATIVE_MODEL_REJECTION_MESSAGE,
|
|
diagnostic: { ...NATIVE_MODEL_REJECTION_DIAGNOSTIC },
|
|
};
|
|
},
|
|
/** Diagnostics only: the failed turn that `forTerminal` did not classify. */
|
|
unclassifiedTurnFailure(turnId: string | null | undefined, terminal: PrpTerminalState) {
|
|
if (!turnId || terminal.turnTerminalState !== "failed" || terminal.runTerminalState !== "failed" ||
|
|
turnId === overloadedTurnId || turnId === rejectedTurnId) return null;
|
|
const diagnostic = failedTurns.get(turnId);
|
|
return diagnostic ? { ...diagnostic } : null;
|
|
},
|
|
};
|
|
}
|