Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 32 additions & 13 deletions backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
using CodeSpace.Core.Services.Workflows.Llm;
using CodeSpace.Core.Services.Workflows.Planning.Planners;
using CodeSpace.Core.Services.Agents.Sandbox;
using CodeSpace.Core.Services.Agents.Sandbox.Exceptions;
using CodeSpace.Core.Services.Agents.Sandbox.Isolation;
using CodeSpace.Core.Services.Agents.Sandbox.Runners;
using CodeSpace.Core.Services.Agents.Tools;
Expand Down Expand Up @@ -622,17 +623,32 @@
}
}

var reviseSpec = BuildSpec(reviseTask);
// A revise goal restates the contract, so it can cross a harness's own input cap the original goal fit under
// (Codex's 1,048,576 characters) — warm, or once rebuilt cold below. That refusal is about this round, not
// the run: the last round ran and was graded, and its result stands — the loop stops the way a spend
// refusal stops it, with nothing to settle.
SandboxSpec reviseSpec;
bool roundRanCold;

// The same verdict for the round's own session: warm only when the pipe can carry it. Only the
// conversation and the goal change — a model escalation already applied to this round stands.
var roundRanCold = ContinuationOverflowsTheFrame(reviseTask, reviseSpec);

if (roundRanCold)
try
{
var cold = BuildReviseTask(effectiveTask, result, reason, mayResume: false);
reviseTask = reviseTask with { Goal = cold.Goal, ResumeFromSessionId = null, RestoredTranscript = null };
reviseSpec = BuildSpec(reviseTask);

// The same verdict for the round's own session: warm only when the pipe can carry it. Only the
// conversation and the goal change — a model escalation already applied to this round stands.
roundRanCold = ContinuationOverflowsTheFrame(reviseTask, reviseSpec);

if (roundRanCold)
{
var cold = BuildReviseTask(effectiveTask, result, reason, mayResume: false);
reviseTask = reviseTask with { Goal = cold.Goal, ResumeFromSessionId = null, RestoredTranscript = null };
reviseSpec = BuildSpec(reviseTask);
}
}
catch (SandboxArgumentTooLongException refusal)
{
await AppendReviseStopEventAsync(owner, ReviseSizeStoppedPrefix, refusal.Message, cancellationToken).ConfigureAwait(false);
break;
}

var priorUsage = result.TokenUsage;
Expand All @@ -648,7 +664,7 @@
if (spendClaim is { RefusedDetail: { } roundRefusal })
{
spendClaim = null;
await AppendReviseBudgetStopEventAsync(owner, roundRefusal, cancellationToken).ConfigureAwait(false);
await AppendReviseStopEventAsync(owner, ReviseBudgetStoppedPrefix, roundRefusal, cancellationToken).ConfigureAwait(false);
break;
}

Expand Down Expand Up @@ -2794,17 +2810,20 @@
/// <summary>The budget-stopped-revision announcement's pinned prefix — the operator-visible marker that a round was never bought, not that it ran and failed.</summary>
internal const string ReviseBudgetStoppedPrefix = "Revision stopped — the run's cost cap had no headroom for another attempt";

/// <summary>Announce that the revise loop stopped because the ledger refused the NEXT invocation's claim. The round that already succeeded keeps its result: throwing away finished work because the following attempt cannot be afforded would lose what the operator already paid for. Best-effort, exactly like the stalled announcement above.</summary>
private async Task AppendReviseBudgetStopEventAsync(AgentRunOwnerToken owner, string detail, CancellationToken cancellationToken)
/// <summary>The timeline's account of a revision the harness refused to build for its size: the last graded round's result stands.</summary>
internal const string ReviseSizeStoppedPrefix = "Revision stopped — the revised instruction is past what this agent can be handed, so the last round's result stands";

/// <summary>Announce that the revise loop stopped before the NEXT round was bought — the ledger refused its claim, or the harness refused to build it for its size. The round that already ran keeps its result: throwing away finished work because the following attempt cannot be run would lose what the operator already paid for. Best-effort, exactly like the stalled announcement above.</summary>
private async Task AppendReviseStopEventAsync(AgentRunOwnerToken owner, string reason, string detail, CancellationToken cancellationToken)
{
var runId = owner.RunId;
try
{
await _runs.AppendEventAsync(owner, new AgentEvent { Kind = AgentEventKind.Warning, Text = $"{ReviseBudgetStoppedPrefix}. {detail}" }, cancellationToken).ConfigureAwait(false);
await _runs.AppendEventAsync(owner, new AgentEvent { Kind = AgentEventKind.Warning, Text = $"{reason}. {detail}" }, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is not OperationCanceledException and not AgentRunOwnershipLostException)
{
_logger.LogWarning(ex, "Agent run {RunId}: could not record the revise-budget-stop event", runId);
_logger.LogWarning(ex, "Agent run {RunId}: could not record the revise-stop event", runId);
}
}

Expand Down Expand Up @@ -4894,10 +4913,10 @@
/// <summary>The same reconstruction from a payload that came from somewhere other than the row — an offloaded one fetched back out of the artifact store.</summary>
private static AgentEvent ReplayedEvent(AgentEventKind kind, string? text, string? dataJson)
{
if (dataJson is not { Length: > 0 } json) return new AgentEvent { Kind = kind, Text = text };

Check warning on line 4916 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / recurring jobs fire (worker host · Postgres)

Possible null reference assignment.

Check warning on line 4916 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (E2ETests · HTTP · Postgres)

Possible null reference assignment.

Check warning on line 4916 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

Check warning on line 4916 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

Check warning on line 4916 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (IntegrationTests · Postgres)

Possible null reference assignment.

try { using var doc = JsonDocument.Parse(json); return new AgentEvent { Kind = kind, Text = text, Data = doc.RootElement.Clone() }; }

Check warning on line 4918 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / recurring jobs fire (worker host · Postgres)

Possible null reference assignment.

Check warning on line 4918 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (E2ETests · HTTP · Postgres)

Possible null reference assignment.

Check warning on line 4918 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

Check warning on line 4918 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

Check warning on line 4918 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (IntegrationTests · Postgres)

Possible null reference assignment.
catch (JsonException) { return new AgentEvent { Kind = kind, Text = text }; }

Check warning on line 4919 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / recurring jobs fire (worker host · Postgres)

Possible null reference assignment.

Check warning on line 4919 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (E2ETests · HTTP · Postgres)

Possible null reference assignment.

Check warning on line 4919 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

Check warning on line 4919 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (UnitTests)

Possible null reference assignment.

Check warning on line 4919 in backend/src/CodeSpace.Core/Services/Agents/AgentRunExecutor.cs

View workflow job for this annotation

GitHub Actions / dotnet test (IntegrationTests · Postgres)

Possible null reference assignment.
}

/// <summary>Ask the row, on a token of its own, whether the run actually reached a terminal state — the only honest answer to "did the landing take?" once an exception has been raised somewhere after the fenced write.</summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,35 @@ namespace CodeSpace.Core.Services.Agents;
/// </summary>
public static class AgentTerminalOutcomeReader
{
/// <summary>
/// The <see cref="AgentRunResult.ExitReason"/> a harness's folder stamps when its CLI's OWN terminal event says the
/// request is larger than the model's context window — the provider refused it, or the CLI did before sending it:
/// a field or status the CLI wrote, never a phrase in the agent's prose. Harness-agnostic, like the rest of this reader: each folder knows its CLI's shape, and every
/// consumer (the retry-cause classifier, and through it the agent.run node's retry verdict) keys on this one code.
/// Pinned by a unit test (Rule 8) so the producers and the verdict cannot drift apart.
/// </summary>
public const string ContextWindowExceededExitReason = "context-window-exceeded";

/// <summary>
/// What an OpenAI-compatible gateway (vLLM, LiteLLM) says when it refuses a request as over the model's window —
/// the overflow shape neither CLI stamps as its own, because the gateway speaks in its own words and codes (vLLM
/// sends <c>code: 400</c>, LiteLLM <c>code: "400"</c>, with the reason only in the message). Read ONLY off a
/// provider refusal body a CLI relays (Claude's 400 <c>api_error</c> result, Codex's <c>turn.failed</c>) — never
/// off an agent's prose, which is how a crash or a rubric used to pass for a diagnosis. Shared so the two folds
/// cannot disagree about the same gateway.
/// </summary>
public static bool NamesAContextOverflow(string providerMessage) =>
GatewayOverflowMarkers.Any(marker => providerMessage.Contains(marker, StringComparison.OrdinalIgnoreCase));

private static readonly string[] GatewayOverflowMarkers =
{
"maximum context length is", // vLLM, and OpenAI's own wording that gateways pass through
"context_length_exceeded", // OpenAI's error code, embedded in a gateway's message
"ContextWindowExceededError", // LiteLLM's class name, stamped only on a window refusal whatever the upstream
"prompt is too long", // Anthropic's refusal, relayed by a gateway in front of a Claude model
"exceed context limit", // Anthropic's other refusal (input length and max_tokens)
};

/// <summary>
/// True when the last Completed-or-Error event in the stream is an Error — i.e. the harness itself reported
/// the run failed, even if the OS exit code was 0. False when no such event exists (nothing to reconcile
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Text.Json;
using CodeSpace.Core.Services.Agents.Sandbox;
using CodeSpace.Messages.Agents;
using CodeSpace.Messages.Enums;
Expand All @@ -13,8 +14,14 @@ namespace CodeSpace.Core.Services.Agents.Harnesses.Claude;
internal sealed class ClaudeCodeResultFolder : IAgentEventFolder
{
private readonly AgentResultFold _fold = new();
private JsonElement? _lastErrorLine;

public void Add(AgentEvent normalized) => _fold.Add(normalized);
public void Add(AgentEvent normalized)
{
_fold.Add(normalized);

if (normalized.Kind == AgentEventKind.Error) _lastErrorLine = normalized.Data;
}

public AgentRunResult BuildResult(AgentRunFacts facts, int exitCode, string diagnostics)
{
Expand Down Expand Up @@ -52,8 +59,51 @@ public AgentRunResult BuildResult(AgentRunFacts facts, int exitCode, string diag
?? (string.IsNullOrWhiteSpace(summary) ? null : summary)
?? AgentDiagnosticExcerpt.Explain($"claude exited with code {SandboxExitCode.Describe(exitCode)}", diagnostics);

var exitReason = exitCode != 0 ? "non-zero-exit" : "harness-reported-failure";
var exitReason = _fold.ReportedFailure && RefusedAsOverContextWindow(_lastErrorLine) ? AgentTerminalOutcomeReader.ContextWindowExceededExitReason
: exitCode != 0 ? "non-zero-exit" : "harness-reported-failure";

return new AgentRunResult { Status = AgentRunStatus.Failed, ExitReason = exitReason, Summary = summary, ChangedFiles = changedFiles, Error = error, TokenUsage = usage, SessionId = sessionId, Model = model };
}

/// <summary>
/// Whether the CLI's own terminal result line says the request is larger than the model's context window. Read off
/// fields the CLI writes and, for the gateway shape, off the refusal body it relays — never off the agent's prose,
/// which is how a crash's last message or a rubric's wording would otherwise pass for a diagnosis.
///
/// <para>Two verdicts are the CLI's own: <c>prompt_too_long</c>, the provider refused the request; and
/// <c>blocking_limit</c>, the CLI's own token estimate is past the window so it refused locally and sent nothing
/// (observed from 2.1.226 and the pinned 2.1.263 for a goal past roughly 760 KB on a 200K-token model). Either way a
/// respawn sends at least as much. The third shape is a gateway's 400 relayed as <c>api_error</c>. A 5xx is the
/// gateway failing, not the model refusing, and a connection-level failure carries <c>api_error_status: null</c>
/// — both stay ordinary, retryable failures whatever their text says.</para>
/// </summary>
private static bool RefusedAsOverContextWindow(JsonElement? line)
{
if (line is not { ValueKind: JsonValueKind.Object } result) return false;

var terminalReason = ReadString(result, "terminal_reason");

if (terminalReason is "prompt_too_long" or "blocking_limit") return true;

return terminalReason == "api_error" && IsClientRefusal(result) && AgentTerminalOutcomeReader.NamesAContextOverflow(ReadString(result, "result"));
}

/// <summary>The result line's <c>api_error_status</c> is a 400. Null (a connection that never got an answer), absent, or any other kind is not.</summary>
private static bool IsClientRefusal(JsonElement result) =>
result.TryGetProperty("api_error_status", out var status) && status.ValueKind == JsonValueKind.Number && status.TryGetInt32(out var code) && code == 400;

/// <summary>A string field of the CLI's line, or "" when it is absent or another kind. When it is not valid text (an unpaired surrogate escape a relayed gateway body can carry parses, then throws on read), its escaped text stands in: the words the fold looks for are ASCII, and the fold must never be where a run's work is dropped.</summary>
private static string ReadString(JsonElement root, string key)
{
if (!root.TryGetProperty(key, out var value) || value.ValueKind != JsonValueKind.String) return "";

try
{
return value.GetString() ?? "";
}
catch (InvalidOperationException)
{
return value.GetRawText();
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
using System.Text.Json;
using CodeSpace.Core.DependencyInjection;
using CodeSpace.Core.Services.Agents.Mcp;
using CodeSpace.Core.Services.Agents.Sandbox.Exceptions;
using CodeSpace.Core.Services.Agents.Skills;
using CodeSpace.Messages.Agents;
using CodeSpace.Messages.Enums;
Expand Down Expand Up @@ -136,8 +137,18 @@ public sealed class CodexHarness : IAgentHarness, IAgentHarnessBinary, IAgentHar

public IReadOnlyList<string> Models { get; } = new[] { "gpt-5.3-codex", "gpt-5.4", "gpt-5.4-codex" };

/// <summary>
/// The most characters Codex accepts in one input. codex-rs refuses a longer <c>turn/start</c> with
/// <c>input_too_large</c> before any model request — verified against 0.142.2 (the worker's pin) and 0.147.0 — and
/// says so on stderr only. Counted as Unicode scalar values, the way Rust counts a string's characters. Pinned by a
/// test; it moves by PR with the CLI.
/// </summary>
public const int MaxInputCharacters = 1_048_576;

public SandboxSpec BuildInvocation(AgentTask task)
{
EnsureWithinInputCap(task.Goal);

// P3.2: a CONTINUE re-stage rewrites the `exec --json` seed to `exec resume <id> --json` so Codex picks up the
// prior thread. The subcommand must follow `exec` directly; --model, the `-c` overrides (incl. the sandbox on
// the resume path — see AppendSandbox), and the stdin `-` positional follow. Null (a fresh run) → the plain seed.
Expand Down Expand Up @@ -287,6 +298,19 @@ private static bool TryReadModelKey(JsonElement obj, out string model)
return model.Length > 0;
}

/// <summary>
/// Refuse a goal Codex would refuse, before a sandbox is provisioned for it. The limit belongs to this CLI, not to
/// the launch (Rule 7), so it lives on this harness; the refusal is the same terminal, non-retried one an argument
/// the kernel cannot take gets, because every attempt meets the identical cap.
/// </summary>
private static void EnsureWithinInputCap(string goal)
{
var characters = goal.EnumerateRunes().Count();

if (characters > MaxInputCharacters)
throw new SandboxArgumentTooLongException($"the agent's goal is {characters} characters; codex accepts at most {MaxInputCharacters} in one input and refuses anything longer before any model request. This is an input-size limit of the codex CLI, not a memory limit — shorten the goal, or run this agent on a harness without that cap.");
}

/// <summary>
/// The config-home files the runner materializes: (1) B1 — <c>AGENTS.md</c> carrying the persona + the always-on
/// operating contract (Codex's native instruction channel, since <c>exec</c> has no system-prompt flag; codex loads
Expand Down
Loading
Loading