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
16 changes: 14 additions & 2 deletions docs/reference/agent-quirks.md
Original file line number Diff line number Diff line change
Expand Up @@ -486,8 +486,9 @@ shim rides argv (`python3 -I -c`, which also keeps the worktree off
kinds like `notification`/`protocol_error`/`unhandled_request`/
`config_option_update` prove nothing either way, and a window with no
pre-prompt devin.acp envelope at all proves nothing); raw
stderr is never scanned for it. Envelopes positioned after
`turn_complete` are never shim output — the shim exits immediately —
stderr is never scanned for it. Envelopes positioned after a terminal
envelope (`turn_complete` / `output_limit_exhausted`) are never shim
output — the shim exits immediately —
so enumeration stops there and post-terminal forged lines cannot flip
a completed run or feed transient classification. A `turn_error` whose error `data`
carries `cognition.ai/retryable: true` is forwarded as
Expand All @@ -510,6 +511,17 @@ shim rides argv (`python3 -I -c`, which also keeps the worktree off
relay delivers inside `codeybox.stderr` envelopes;
`DevinTerminalDiagnoser` unwraps and lifts the first such line as a
fallback.
- Output-token ceiling: a `session/prompt` response with stopReason
`max_tokens` is never a normal finish — the provider cut the turn off
mid-task (verified: a 128000-output-token turn ended the item as "no
changes"). While the hot-reloadable `CodeyBox:Devin:MaxTokenContinuations`
bound lasts (default 8, 0 disables), the shim records a
`token_continuation` envelope and re-prompts the SAME session with the
shared truncation-continuation wording, accumulating final text across
the continuations; a spent bound ends the run with an
`output_limit_exhausted` terminal envelope (exit 2), which the pipeline
classifies as the distinct `output-limit-exhausted` failure kind rather
than quota, transient, or "produced no changes".
- Envelope provenance: `devin.acp` lines are claimed by their type tag, so
nothing agent-controlled may reach the claimable stream as a bare line.
The shim retargets its own fd 2 — inherited by the CLI and every tool
Expand Down
58 changes: 42 additions & 16 deletions src/CodeyBox.Agents.Devin/DevinAcpEnvelope.cs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,30 @@ internal static class DevinAcpEnvelope
internal const string EventTurnError = "turn_error";
internal const string EventFatal = "fatal";

/// <summary>
/// Non-terminal event emitted each time the shim re-prompts the SAME
/// session with the truncation-continuation prompt after a
/// <c>turn_complete</c> carrying stop reason <c>max_tokens</c> (see
/// <see cref="CodeyBox.Core.OutputTokenLimit"/>). Carries the 1-based
/// <c>attempt</c>, the configured <c>maxContinuations</c> bound, and the
/// <c>trigger</c> stop reason that fired. Deliberately NOT shaped like a
/// terminal stop-reason field, so truncation detectors never read a
/// successful continued run as still truncated.
/// </summary>
internal const string EventTokenContinuation = "token_continuation";

/// <summary>
/// Terminal event emitted when the shim's same-session continuation
/// bound is exhausted: the latest <c>session/prompt</c> still answered
/// stop reason <c>max_tokens</c> after <c>maxContinuations</c>
/// continuations. Carries the spent <c>attempts</c> count, the
/// <c>maxContinuations</c> bound, and a <c>message</c> diagnostic the
/// host lifts into the terminal diagnostic. Enumeration stops after it
/// exactly like <c>turn_complete</c>: the shim exits immediately after
/// emitting it.
/// </summary>
internal const string EventOutputLimitExhausted = "output_limit_exhausted";

/// <summary>
/// Emitted immediately after <c>session/prompt</c> is sent — the
/// boundary between the pre-prompt handshake (where fd-2 stderr is
Expand Down Expand Up @@ -180,14 +204,14 @@ internal static IEnumerable<TypedEnvelope> EnumerateEnvelopes(string? stdout)
/// devin.acp envelope, and lines truncated mid-write are all skipped —
/// the capture may carry leading CLI noise or be cut by an output cap.
///
/// <para>Enumeration stops after the first <c>turn_complete</c>
/// envelope: the shim exits immediately after emitting it, so any
/// <c>devin.acp</c> line positioned after it cannot be shim output — a
/// same-uid process can still append forged envelope lines to the exec
/// stdout pipe through <c>/proc/&lt;pid&gt;/fd</c> after the turn ends.
/// Consumers therefore never see post-terminal forged envelopes through
/// this enumerator (or through <see cref="EnumerateBounded"/>, which is
/// built on it).</para>
/// <para>Enumeration stops after the first terminal envelope
/// (<c>turn_complete</c> or <c>output_limit_exhausted</c>): the shim
/// exits immediately after emitting it, so any <c>devin.acp</c> line
/// positioned after it cannot be shim output — a same-uid process can
/// still append forged envelope lines to the exec stdout pipe through
/// <c>/proc/&lt;pid&gt;/fd</c> after the turn ends. Consumers therefore
/// never see post-terminal forged envelopes through this enumerator (or
/// through <see cref="EnumerateBounded"/>, which is built on it).</para>
/// </summary>
internal static IEnumerable<Envelope> Enumerate(string? stdout)
{
Expand All @@ -196,7 +220,8 @@ internal static IEnumerable<Envelope> Enumerate(string? stdout)
if (typed.Type == EnvelopeType && typed.Event is { } eventName)
{
yield return new Envelope(eventName, typed.Root);
if (string.Equals(eventName, EventTurnComplete, StringComparison.Ordinal))
if (string.Equals(eventName, EventTurnComplete, StringComparison.Ordinal)
|| string.Equals(eventName, EventOutputLimitExhausted, StringComparison.Ordinal))
yield break;
}
}
Expand All @@ -210,13 +235,14 @@ internal static IEnumerable<Envelope> Enumerate(string? stdout)
/// envelopes of that window — the bound policy for detectors that scan
/// only <c>devin.acp</c> envelopes. Both bounds favour the tail because
/// the signatures they scan for (terminal <c>model_mismatch</c>/
/// <c>turn_error</c>/<c>fatal</c> envelopes) are the last lines of the
/// capture: a chatty turn — including <c>codeybox.stderr</c> relay lines
/// flushed after the terminal envelope — must not push the signature
/// out of the examined set. Non-devin.acp lines are parsed but never
/// counted against the envelope bound. Like <see cref="Enumerate"/>,
/// enumeration stops at <c>turn_complete</c> so post-terminal forged
/// lines cannot consume the examined set.
/// <c>turn_error</c>/<c>fatal</c>/<c>output_limit_exhausted</c>
/// envelopes) are the last lines of the capture: a chatty turn —
/// including <c>codeybox.stderr</c> relay lines flushed after the
/// terminal envelope — must not push the signature out of the examined
/// set. Non-devin.acp lines are parsed but never counted against the
/// envelope bound. Like <see cref="Enumerate"/>, enumeration stops at a
/// terminal envelope so post-terminal forged lines cannot consume the
/// examined set.
/// </summary>
internal static IEnumerable<Envelope> EnumerateBounded(string? stdout)
{
Expand Down
5 changes: 5 additions & 0 deletions src/CodeyBox.Agents.Devin/DevinAcpOutcome.cs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ internal enum TerminalEvent
TurnError,
Fatal,
ModelMismatch,
OutputLimitExhausted,
}

internal sealed record Outcome(TerminalEvent Event, string? Diagnostic);
Expand Down Expand Up @@ -76,6 +77,10 @@ internal static Outcome Extract(string? stdout)
lastEvent = TerminalEvent.Fatal;
lastDiagnostic = Describe("fatal", envelope.Root);
break;
case DevinAcpEnvelope.EventOutputLimitExhausted:
lastEvent = TerminalEvent.OutputLimitExhausted;
lastDiagnostic = Describe("output-token limit exhausted", envelope.Root);
break;
}
}

Expand Down
96 changes: 87 additions & 9 deletions src/CodeyBox.Agents.Devin/DevinAgentRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ public sealed class DevinAgentRunner : CliAgentRunnerBase, IAgentDefaultModelPro
"devin credentials",
MaterialiseFromSandboxEnvironmentWhenCredentialMissing: true);
private readonly AgentDefaultsSnapshot? _defaults;
private readonly DevinOptionsAccessor? _options;

public DevinAgentRunner() : this(defaults: null) { }

Expand All @@ -167,8 +168,26 @@ public DevinAgentRunner() : this(defaults: null) { }
/// sourced from hot-reloadable config rather than a hardcoded literal.
/// </param>
public DevinAgentRunner(AgentDefaultsSnapshot? defaults)
: this(defaults, options: null)
{
}

/// <param name="defaults">
/// Live snapshot of per-agent default model IDs (see
/// <see cref="AgentDefaultsSnapshot"/>). Supplies the <c>--model</c> value
/// when a caller does not pass an explicit model, so the dispatch model is
/// sourced from hot-reloadable config rather than a hardcoded literal.
/// </param>
/// <param name="options">
/// Resolver for the hot-reloadable <see cref="DevinOptions"/> (bound
/// under <c>CodeyBox:Devin</c>). Read on every dispatch so an operator
/// edit lands on the next run without a restart. Null keeps the
/// <see cref="OutputTokenLimit.DefaultMaxContinuations"/> default.
/// </param>
public DevinAgentRunner(AgentDefaultsSnapshot? defaults, DevinOptionsAccessor? options)
{
_defaults = defaults;
_options = options;
}

public override AgentKind Kind => AgentKind.Devin;
Expand Down Expand Up @@ -266,7 +285,12 @@ protected override AgentInvocation BuildInvocation(
_ = captureStructuredStream;

return new AgentInvocation(
["bash", "-c", BuildAcpDispatchScript(AcpShimArgs(Binary, EffectiveModelId(modelId), FullAutonomyAcpMode))],
["bash", "-c", BuildAcpDispatchScript(AcpShimArgs(
Binary,
EffectiveModelId(modelId),
FullAutonomyAcpMode,
MaxTokenContinuations,
ProviderTransientRetryPolicy.BuildTruncationContinuePrompt(phase: null)))],
Stdin: DevinAcpShim.BuildDispatchStdin(prompt),
StdoutIsEnvelopeFramed: true);
}
Expand All @@ -287,15 +311,50 @@ protected override AgentInvocation BuildInvocation(
private string? EffectiveModelId(string? modelId)
=> !string.IsNullOrEmpty(modelId) ? modelId : DefaultModelId;

/// <summary>
/// Bounded same-session continuations the shim may send after
/// output-limit stops before giving up: the hot-reloadable
/// <c>CodeyBox:Devin:MaxTokenContinuations</c> value clamped through
/// <see cref="OutputTokenLimit.ClampContinuations"/>. Read on every
/// dispatch so an operator edit lands on the next run.
/// </summary>
public int MaxTokenContinuations
{
get
{
try
{
return OutputTokenLimit.ClampContinuations(
_options?.Invoke()?.MaxTokenContinuations ?? OutputTokenLimit.DefaultMaxContinuations);
}
catch (Exception)
{
return OutputTokenLimit.DefaultMaxContinuations;
}
}
}

/// <summary>
/// The shim argv (after the script body) for one ACP turn.
/// <paramref name="modelId"/> is passed verbatim to
/// <c>devin acp --model</c> — the configured swe-2 id, never a fallback;
/// omit when null so the account server-side default applies.
/// <paramref name="mode"/> is applied via <c>session/set_mode</c> before
/// the prompt (<see cref="FullAutonomyAcpMode"/> for dispatches).
/// <paramref name="maxTokenContinuations"/> bounds the shim's
/// same-session re-prompts after a <c>max_tokens</c> stop; the
/// continuation text travels as <paramref name="continuationPrompt"/>
/// (the shared <see cref="ProviderTransientRetryPolicy"/>
/// truncation-continue wording) so the prompt vocabulary stays in one
/// place instead of drifting between the C# dispatch and the shim's
/// fallback default.
/// </summary>
internal static IReadOnlyList<string> AcpShimArgs(string binary, string? modelId, string? mode)
internal static IReadOnlyList<string> AcpShimArgs(
string binary,
string? modelId,
string? mode,
int maxTokenContinuations = OutputTokenLimit.DefaultMaxContinuations,
string? continuationPrompt = null)
{
var args = new List<string> { "--binary", binary };
if (!string.IsNullOrEmpty(modelId))
Expand All @@ -308,6 +367,13 @@ internal static IReadOnlyList<string> AcpShimArgs(string binary, string? modelId
args.Add("--mode");
args.Add(mode);
}
args.Add("--max-token-continuations");
args.Add(OutputTokenLimit.ClampContinuations(maxTokenContinuations).ToString());
if (!string.IsNullOrEmpty(continuationPrompt))
{
args.Add("--continuation-prompt");
args.Add(continuationPrompt);
}
return args;
}

Expand Down Expand Up @@ -566,12 +632,20 @@ public override async Task<AgentResult> RunResumedAsync(
/// Maps the exec result through the shim's terminal outcome. Two failure
/// surfaces exist: the shim reports protocol/turn failures as typed
/// terminal envelopes (<c>turn_error</c> / <c>model_mismatch</c> /
/// <c>fatal</c>), and the CLI itself reports auth/startup failures on
/// stderr as <c>Error: …</c> lines (verified against devin 3000.11.1:
/// "Error: Not logged in…" exits nonzero before ACP starts). Whichever
/// fired is lifted into <see cref="AgentResult.TerminalDiagnostic"/> so
/// the pipeline can classify it; without this a quota/auth give-up with
/// no file changes terminal-fails as "produced no changes".
/// <c>fatal</c> / <c>output_limit_exhausted</c>), and the CLI itself
/// reports auth/startup failures on stderr as <c>Error: …</c> lines
/// (verified against devin 3000.11.1: "Error: Not logged in…" exits
/// nonzero before ACP starts). Whichever fired is lifted into
/// <see cref="AgentResult.TerminalDiagnostic"/> so the pipeline can
/// classify it; without this a quota/auth give-up with no file changes
/// terminal-fails as "produced no changes". An
/// <c>output_limit_exhausted</c> terminal additionally stamps
/// <see cref="AgentResult.OutputTokenLimitExhausted"/> so the pipeline
/// records the distinct output-limit-exhausted failure kind instead of
/// quota, transient, or no-changes — the shim already spent the
/// configured same-session continuation bound, so parking for retry
/// would only burn budget re-running a task that cannot fit the
/// ceiling.
/// </summary>
private static AgentResult PostProcessAcpResult(AgentResult result)
{
Expand Down Expand Up @@ -606,7 +680,11 @@ private static AgentResult PostProcessAcpResult(AgentResult result)
}

if (outcome.Diagnostic is { } acpFailure)
return result with { TerminalDiagnostic = acpFailure };
return result with
{
TerminalDiagnostic = acpFailure,
OutputTokenLimitExhausted = outcome.Event == DevinAcpOutcome.TerminalEvent.OutputLimitExhausted,
};

if (DevinTerminalDiagnoser.TryExtractTerminalError(result.Stderr, result.Stdout) is { } terminalError)
return result with { TerminalDiagnostic = terminalError };
Expand Down
40 changes: 40 additions & 0 deletions src/CodeyBox.Agents.Devin/DevinOptions.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
using CodeyBox.Core;

namespace CodeyBox.Agents.Devin;

/// <summary>
/// Hot-reloadable operator knobs for the <c>devin</c> runner, bound under
/// <c>CodeyBox:Devin</c>. Kept in the agents assembly (rather than the API
/// composition root) so the runner stays free of
/// <c>Microsoft.Extensions.Options</c> — <c>Program.cs</c> binds this POCO
/// and hands the runner a <see cref="DevinOptionsAccessor"/> delegate (same
/// shape as <c>ClineOptionsAccessor</c>).
/// </summary>
public sealed class DevinOptions
{
/// <summary>
/// How many times the ACP shim re-prompts the SAME session with the
/// truncation-continuation prompt after a <c>turn_complete</c> carrying
/// stop reason <c>max_tokens</c> (see
/// <see cref="OutputTokenLimit.StopReasonValue"/>) before giving up.
/// Each continuation emits a <c>token_continuation</c> envelope into the
/// agent stream; an exhausted bound ends the run with an
/// <c>output_limit_exhausted</c> terminal envelope, which the pipeline
/// classifies as the distinct output-limit-exhausted failure kind rather
/// than a generic no-changes failure. Zero disables continuation (the
/// first output-limit stop ends the turn). The runner clamps this
/// through <see cref="OutputTokenLimit.ClampContinuations"/>, so
/// out-of-range values degrade to the nearest bound instead of throwing
/// on the dispatch path.
/// </summary>
public int MaxTokenContinuations { get; set; } = OutputTokenLimit.DefaultMaxContinuations;
}

/// <summary>
/// Resolver for the runner's hot-reloadable <see cref="DevinOptions"/>.
/// Wrapped behind a delegate so the runner DI registration does not depend on
/// <c>IOptionsMonitor</c> directly — keeps the agents assembly free of
/// Microsoft.Extensions.Options (same shape as
/// <c>ClineOptionsAccessor</c>).
/// </summary>
public delegate DevinOptions DevinOptionsAccessor();
1 change: 1 addition & 0 deletions src/CodeyBox.Agents.Devin/DevinStreamParser.cs
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ protected override ParsedEvent ParseEvent(JsonElement root)
FinalText: eventName is DevinAcpEnvelope.EventTurnError
or DevinAcpEnvelope.EventFatal
or DevinAcpEnvelope.EventModelMismatch
or DevinAcpEnvelope.EventOutputLimitExhausted
? FirstString(root, "message")
: null,
IsRecognized: true),
Expand Down
Loading
Loading