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
52 changes: 52 additions & 0 deletions backend/src/CodeSpace.Core/Services/Agents/AcceptanceArgv.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
using System.Text.Json;

namespace CodeSpace.Core.Services.Agents;

/// <summary>
/// The ONE place the shape of an acceptance argv is decided: which ones can be run at all
/// (<see cref="NormalizeOrNull"/>) and how the supervisor surfaces below show one (<see cref="AsLine"/>). Every
/// acceptance command — the operator's floor (<c>SupervisorGoalConfig.AcceptanceChecks</c>), a supervisor unit's or a
/// stop payload's check, a task's own oracle — is spawned with NO shell: the first element is the program and the rest
/// are its arguments. So every reader that decides whether such a command exists (the launch projections that bake it,
/// the rehydrate and the stop grade that run it, the in-loop hook and the local verifier that wrap it) must agree on
/// what an unrunnable one is, or one of them promises a command another refused to read.
/// </summary>
public static class AcceptanceArgv
{
/// <summary>
/// The argv as its own copy when it can be spawned, else null. Two things make one unrunnable, and only these: a
/// blank FIRST element — there is no program, and the argv is never repaired by promoting a later element into that
/// slot, which would run a command nobody wrote — and a null element or a NUL anywhere, which cannot be handed to
/// exec. Every other element is kept verbatim, blank ones after the first included: <c>["sh", " ", "check.sh"]</c>
/// is three arguments, and the element boundaries are the command.
/// </summary>
public static IReadOnlyList<string>? NormalizeOrNull(IReadOnlyList<string>? argv) => argv is { Count: > 0 } && !string.IsNullOrWhiteSpace(argv[0]) && argv.All(token => token is not null && !token.Contains('\0')) ? argv.ToArray() : null;

/// <summary>
/// A check's command as the three supervisor surfaces below show it — a model reading a prompt, a human reading an
/// approval card: a JSON array of strings on ONE line, cut at <paramref name="maxChars"/> and marked with an
/// ellipsis, never shell text. For a TestsPass check the array is an argv, and the grader spawns every acceptance argv
/// with NO shell (<c>TestsPassGrader</c> hands the first element to the runner as the program
/// and the rest as its arguments), so a shell-looking line misleads: <c>--filter Category=Unit|Category=Smoke</c>
/// reads as a pipe, and a joined line cannot tell <c>["sh", " ", "check.sh"]</c> from two arguments, or
/// <c>[" ", "dotnet", "test"]</c> from <c>dotnet test</c>. The array keeps every element boundary and every
/// character — <c>WorkflowJson.InterpolatedText</c>, the repo's own way to write an array into prompt text, leaves
/// the quote, pipe, ampersand, angle brackets and plus as themselves — and it escapes a newline, so the line is one
/// line whatever the argv holds. For the other kinds (<c>ArtifactPresent</c>, <c>LlmJudge</c>,
/// <c>ArtifactSchema</c>, <c>CitationsResolve</c>) the same list holds repo paths, not an argv, and the array keeps
/// their boundaries just as well.
///
/// <para>Exactly three call sites come through here: the supervisor prompt's operator-floor block, each per-subtask
/// check line, and the amendment card's proposed check, so those three never write one command two ways. It is NOT
/// the only way a command reaches a reader. Known exceptions: the grader's own <c>$ cmd</c> evidence line
/// (<c>TestsPassGrader</c>), which is a fenced record of what ran and which a failed unit's evidence tail puts into
/// the prompt beside the array line, and the two UI labels, <c>RoomNarrative</c>'s acceptance label and
/// <c>SupervisorPhaseSource</c>'s phase description, which still join with spaces.</para>
/// </summary>
public static string AsLine(IReadOnlyList<string> argv, int maxChars)
{
var json = JsonSerializer.Serialize(argv, Workflows.WorkflowJson.InterpolatedText);

return json.Length <= maxChars ? json : json[..maxChars] + "…";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ public static class InLoopAcceptanceHook
public static bool AppliesTo(AgentTask task) => task.Permissions.WriteScope == AgentWriteScope.Workspace && HasRunnableOracle(task);

/// <summary>Only a well-formed argv oracle can run inside a shell hook. An authored but incomplete contract still requires final grading; file obligations are never interpreted as commands. The shape alone, whatever the write scope.</summary>
public static bool HasRunnableOracle(AgentTask task) => task.Acceptance is { Kind: null or Messages.Agents.Benchmark.BenchmarkGradingKind.TestsPass, Command.Count: > 0 } spec
&& !string.IsNullOrWhiteSpace(spec.Command[0]) && spec.Command.All(value => value != null && !value.Contains('\0'));
public static bool HasRunnableOracle(AgentTask task) => task.Acceptance is { Kind: null or Messages.Agents.Benchmark.BenchmarkGradingKind.TestsPass } spec && AcceptanceArgv.NormalizeOrNull(spec.Command) is not null;

/// <summary>
/// The POSIX-<c>sh</c> Stop-hook script content, harness-PARAMETERIZED. Both Claude Code's <c>settings.json</c>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ public async Task<LocalAcceptanceObservation> ObserveAsync(LocalAcceptanceReques
return null;
}

private static bool ValidArgv(IReadOnlyList<string> argv) => argv.Count > 0 && !string.IsNullOrWhiteSpace(argv[0]) && argv.All(value => value != null && !value.Contains('\0'));
private static bool ValidArgv(IReadOnlyList<string> argv) => AcceptanceArgv.NormalizeOrNull(argv) is not null;
private static bool SamePath(string left, string? right)
{
try { return right != null && Path.GetFullPath(left) == Path.GetFullPath(right); }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1083,17 +1083,11 @@ private static string ClosingMoveFor(SupervisorTurnContext context)
/// decider rendered only the free-text criteria and the system prompt names "the operator's floor" without showing
/// it, so the brain drove the work blind to the one check that decides the run.
///
/// <para>The argv is written as a JSON array of strings, never as shell text. The grader spawns it with NO shell
/// (<c>TestsPassGrader</c> hands the first element to the runner as the program and the rest as its arguments), so a
/// shell-looking line misleads: <c>--filter Category=Unit|Category=Smoke</c> reads as a pipe, and a joined line cannot
/// tell <c>["sh", " ", "check.sh"]</c> from two arguments. The array keeps every element boundary and every
/// character — <c>WorkflowJson.InterpolatedText</c>, the repo's own way to write an array into prompt text, leaves
/// the quote, pipe, ampersand, angle brackets and plus as themselves — and it escapes a newline, which
/// <see cref="BoundOneLine"/> would otherwise flatten into a space.</para>
///
/// <para>The line is bounded like the per-subtask check line: the argv is unbounded operator config and this block is
/// a fixed per-turn cost the tape compaction cannot shrink, so a longer one is cut and marked with the class's
/// ellipsis. Null / empty ⇒ no block ⇒ byte-identical prompt.</para>
/// <para>The argv is written through <see cref="AcceptanceArgv.AsLine"/> — the same renderer the per-subtask check
/// line and the amendment card use (the grader's own fenced evidence line is a separate record of what ran) — and
/// bounded like the per-subtask check line: the argv is unbounded operator
/// config and this block is a fixed per-turn cost the tape compaction cannot shrink, so a longer one is cut and
/// marked with an ellipsis. Null / empty ⇒ no block ⇒ byte-identical prompt.</para>
/// </summary>
private static void AppendOperatorAcceptanceFloor(StringBuilder builder, SupervisorTurnContext context)
{
Expand All @@ -1102,7 +1096,7 @@ private static void AppendOperatorAcceptanceFloor(StringBuilder builder, Supervi
if (context.AcceptanceChecks is not { Count: > 0 } floor) return;

builder.AppendLine(OperatorFloorHeader);
builder.AppendLine($" {BoundOneLine(JsonSerializer.Serialize(floor, Workflows.WorkflowJson.InterpolatedText), maxChars)}");
builder.AppendLine($" {AcceptanceArgv.AsLine(floor, maxChars)}");
builder.AppendLine(DoNotRepeatTheFloorAsTheStopAcceptance);
builder.AppendLine();
}
Expand Down Expand Up @@ -1527,7 +1521,7 @@ private static bool AppendPlanDecision(StringBuilder builder, SupervisorPriorDec
if (effective.WaivedSubtaskIds.Contains(subtask.Id))
builder.AppendLine(" acceptance check: WAIVED by a co-signed amendment — this item carries no verification. WAIVED is not PASSED; do not report it as verified.");
else if (effective.BySubtask.GetValueOrDefault(subtask.Id) is { } acceptance)
builder.AppendLine($" acceptance check ({acceptance.Kind?.ToString() ?? "TestsPass"}){(ReferenceEquals(acceptance, subtask.Acceptance) ? "" : ", AMENDED by a co-signed amendment")}: {BoundOneLine(string.Join(" ", acceptance.Command), maxChars)}");
builder.AppendLine($" acceptance check ({acceptance.Kind?.ToString() ?? "TestsPass"}){(ReferenceEquals(acceptance, subtask.Acceptance) ? "" : ", AMENDED by a co-signed amendment")}: {AcceptanceArgv.AsLine(acceptance.Command, maxChars)}");
}

return true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@ public static class SupervisorAmendAcceptance
/// <summary>The marker phrase EVERY amend card carries — the stable tail detectors match to recognise this card (vs the other three markers / a content ask). Pinned by a unit test so a reword is a visible decision.</summary>
public const string AmendMarker = "Reply 'approve' to apply this acceptance amendment, or describe what to do instead.";

/// <summary>Per-fragment cap on the quoted reason — the card is a headline, not the failure dossier (the failing grade's evidence lives on the tape).</summary>
/// <summary>Per-fragment cap on the quoted reason and on the proposed check's argv line — the card is a headline, not the failure dossier (the failing grade's evidence lives on the tape).</summary>
internal const int MaxQuotedChars = 200;

/// <summary>Rewrite the model's amend proposal into the parked ask card: the question names the target subtask, the waive-vs-amend shape, the reason, and the proposed command; the structured payload rides beside it as the root <c>amend</c> node; the decision-level rationale is appended last, mirroring the projector's own rationale merge (deterministic order → replay-stable idempotency key).</summary>
/// <summary>Rewrite the model's amend proposal into the parked ask card: the question names the target subtask, the waive-vs-amend shape, the reason, and the proposed command — as the JSON array (<see cref="AcceptanceArgv.AsLine"/>; for a TestsPass check the argv the grader would spawn, for a file-oracle kind the paths it reads), never a shell line the human co-signer would read differently from what runs; the structured payload rides beside it as the root <c>amend</c> node; the decision-level rationale is appended last, mirroring the projector's own rationale merge (deterministic order → replay-stable idempotency key).</summary>
public static SupervisorDecision IntoAskHuman(SupervisorAmendAcceptancePayload payload, SupervisorRationale? rationale = null)
{
var ask = JsonSerializer.Serialize(new SupervisorAskHumanPayload { Question = QuestionFor(payload) }, AgentJson.Options);
Expand Down Expand Up @@ -117,7 +117,7 @@ private static string QuestionFor(SupervisorAmendAcceptancePayload payload)
question.Append($"\nReason: {Quote(payload.Reason)}");

if (!payload.Waive && payload.Acceptance is not null)
question.Append($"\nProposed check: {Quote(string.Join(" ", payload.Acceptance.Command))}");
question.Append($"\nProposed check: {AcceptanceArgv.AsLine(payload.Acceptance.Command, MaxQuotedChars)}");

question.Append($"\n{AmendMarker}");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,9 @@ public async Task<SupervisorTurnContext> RehydrateFromDecisionLogAsync(Guid supe
: EmptyGuidSet;

// The operator's OBJECTIVE acceptance command (L4 A3) — the argv a resolve verdict is graded against. Null
// (none / all-blank) → no objective grade runs, the resolver self-report marker stands (byte-identical to pre-A3).
var acceptanceCommand = NormalizeCommand(goalConfig?.AcceptanceChecks);
// (none, or an argv with no program or a NUL) → no objective grade runs, the resolver self-report marker stands
// (byte-identical to pre-A3).
var acceptanceCommand = AcceptanceArgv.NormalizeOrNull(goalConfig?.AcceptanceChecks);

// C3 narrowing: the run's own ORACLE INVENTORY — the floor's program file(s), derived from the operator's
// argv through the SAME extraction every protected grade uses. It is what separates a judge the run owns
Expand Down Expand Up @@ -803,11 +804,11 @@ private async Task<SupervisorPriorDecision> FoldUnitAcceptanceGradeAsync(Supervi
}

var spec = subtaskId is not null && acceptanceBySubtask.TryGetValue(subtaskId, out var found) ? found : null;
var command = spec is not null ? NormalizeCommand(spec.Command) : null;
var command = spec is not null ? AcceptanceArgv.NormalizeOrNull(spec.Command) : null;

// Ungraded — keep the result byte-identical — when the unit has no contract or its command is all-blank
// (the dominant no-contract case). A MULTI-repo unit (RepositoryResults present) is graded PER REPO below,
// mirroring the run-level stop floor's multi-target grade — it is no longer silently skipped.
// Ungraded — keep the result byte-identical — when the unit has no contract or its command has no program
// or carries a NUL (the dominant no-contract case). A MULTI-repo unit (RepositoryResults present) is graded
// PER REPO below, mirroring the run-level stop floor's multi-target grade — it is no longer silently skipped.
if (command is null)
{
graded.Add(results[i]);
Expand Down Expand Up @@ -1363,8 +1364,8 @@ internal async Task<SupervisorExecution> ApplyStopAcceptanceGradeAsync(Superviso

// Each gate carries its FULL spec (triad S7 — a model-check's rubric/schema payload rides the spec); the
// operator floor stays a bare TestsPass argv spec (the operator never authors a kind on this path).
var floorCommand = NormalizeCommand(context.AcceptanceChecks);
var modelCommand = NormalizeCommand(modelAcceptance?.Command);
var floorCommand = AcceptanceArgv.NormalizeOrNull(context.AcceptanceChecks);
var modelCommand = AcceptanceArgv.NormalizeOrNull(modelAcceptance?.Command);

var gates = new (string Label, SupervisorAcceptanceSpec? Spec)[]
{
Expand Down Expand Up @@ -1864,9 +1865,6 @@ private sealed record ResolveContributorRow(Guid Id, Guid TeamId, AgentRunStatus
return ids is { Count: > 0 } ? ids : null;
}

/// <summary>Preserve argv boundaries exactly. A blank executable or invalid token never promotes a later argument into the executable.</summary>
private static IReadOnlyList<string>? NormalizeCommand(IReadOnlyList<string>? command) => command is { Count: > 0 } && !string.IsNullOrWhiteSpace(command[0]) && command.All(a => a is not null && !a.Contains('\0')) ? command.ToArray() : null;

/// <summary>
/// How many WorkflowRun ancestors this supervisor run already has (PR-E E5 depth cap) — walks the
/// <c>parent_run_id</c> chain exactly as <c>SubworkflowService.EnsureWithinDepthAsync</c> does, bounded by
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json;
using CodeSpace.Core.DependencyInjection;
using CodeSpace.Core.Services.Agents;
using CodeSpace.Messages.Dtos.Workflows;
using CodeSpace.Messages.Tasks;
using CodeSpace.Messages.Tasks.Effort;
Expand Down Expand Up @@ -31,9 +32,8 @@ public sealed class SingleAgentDefinitionBuilder : IWorkflowDefinitionBuilder, I
/// <summary>The route's deliverable shape, normalized — an unknown / absent shape reads as <c>code</c>, today's behaviour.</summary>
private static string Shape(TaskBuildContext context) => DeliverableShapes.Normalize(context.Route.DeliverableShape);

/// <summary>The operator's EXECUTABLE floor, blanks dropped. Non-empty ⇒ the operator authored the oracle and it wins over any shape-derived one.</summary>
private static IReadOnlyList<string>? OperatorArgv(TaskBuildContext context) =>
context.AcceptanceChecks is { Count: > 0 } command && !string.IsNullOrWhiteSpace(command[0]) && command.All(c => c is not null && !c.Contains('\0')) ? command.ToArray() : null;
/// <summary>The operator's EXECUTABLE floor, verbatim — blank arguments after the program are arguments. Non-null ⇒ the operator authored a runnable oracle and it wins over any shape-derived one.</summary>
private static IReadOnlyList<string>? OperatorArgv(TaskBuildContext context) => AcceptanceArgv.NormalizeOrNull(context.AcceptanceChecks);

/// <summary>
/// THIS agent's objective oracle. Precedence, and the whole point of the shape axis:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
using System.Text.Json;
using CodeSpace.Core.DependencyInjection;
using CodeSpace.Core.Services.Agents;
using CodeSpace.Core.Services.Agents.Workspace;
using CodeSpace.Messages.Agents;
using CodeSpace.Messages.Dtos.Workflows;
Expand Down Expand Up @@ -136,9 +137,10 @@ private static JsonElement BuildSupervisorConfig(TaskBuildContext context)
// model targets them; NOT executed, distinct from the acceptanceChecks argv floor). Omitted when empty (byte-identical).
AddIfPresent(config, "acceptanceCriteria", context.AcceptanceCriteria is { Count: > 0 } criteria ? criteria.ToList() : null);
// The operator's EXECUTABLE acceptance floor (S4b) — an argv run against the reviewable head at the terminal
// stop; a non-zero exit fails the stop + withholds the branch. Blank entries dropped; omitted when empty
// (byte-identical). The free-text criteria above steer the model; this floor VERIFIES the result.
AddIfPresent(config, "acceptanceChecks", context.AcceptanceChecks is { Count: > 0 } checks && !string.IsNullOrWhiteSpace(checks[0]) && checks.All(c => c is not null && !c.Contains('\0')) ? checks.ToArray() : null);
// stop; a non-zero exit fails the stop + withholds the branch. Baked verbatim, blank arguments after the program
// included; omitted when empty or when it has no program / carries a NUL (byte-identical). The free-text criteria
// above steer the model; this floor VERIFIES the result.
AddIfPresent(config, "acceptanceChecks", AcceptanceArgv.NormalizeOrNull(context.AcceptanceChecks));
// The S3 plan-confirmation gate — every authored plan version parks for the operator's confirmation before any
// agent is created. Omitted when off (byte-identical).
AddIfPresent(config, "requirePlanConfirmation", context.RequirePlanConfirmation ? (object)true : null);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using CodeSpace.Core.Services.Agents;
using CodeSpace.Messages.Contracts;
using CodeSpace.Messages.Tasks;

Expand All @@ -15,7 +16,7 @@ internal static class TaskSpecAcceptanceAssessment
var reason = "Source support is unknown; this proposal is not prefilled as a mandatory check.";
var status = TaskSpecEvidenceStatus.Unknown;
var source = TaskSpecCheckSource.ProposedUnverified;
var validCommand = argv.Length is > 0 and <= 256 && !string.IsNullOrWhiteSpace(argv[0]) && argv.All(t => t is not null && !t.Contains('\0'));
var validCommand = argv.Length <= 256 && AcceptanceArgv.NormalizeOrNull(argv) is not null;
var citations = review?.Citations ?? [];
var validCitations = context is not null && citations.Count is > 0 and <= 8;
foreach (var citation in citations.Take(8))
Expand Down
Loading
Loading