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
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ private static IReadOnlyList<EdgeDefinition> BuildEdges(TaskBuildContext context

// The integrate step sequences between the fan-out and the narration reduce (map → integrate → synth);
// a repo-less graph keeps the original map → synth edge byte-identically.
if (context.AgentProfile?.RepositoryId is not null)
if (IsRepoBound(context))
edges.AddRange(new EdgeDefinition[] { new() { From = "map", To = "integrate" }, new() { From = "integrate", To = "synth" } });
else
edges.Add(new() { From = "map", To = "synth" });
Expand All @@ -149,7 +149,7 @@ private static JsonElement PlannerConfig(TaskBuildContext context)
AddIfPresent(config, "reviewerModelId", context.PlannerReviewMode != ReviewMode.None ? context.ReviewerModelId?.ToString() : null);
// D① grounded plan review — a real read-only agent verifies the plan against the bound repository's tree.
// Rides only when a plan review is active AND the profile binds a repo (else omitted — byte-identical).
var reviewerAgentOn = context.PlannerReviewMode != ReviewMode.None && context.AgentProfile?.ReviewerAgent == true && context.AgentProfile?.RepositoryId is not null;
var reviewerAgentOn = context.PlannerReviewMode != ReviewMode.None && context.AgentProfile?.ReviewerAgent == true && IsRepoBound(context);
AddIfPresent(config, "reviewerAgent", reviewerAgentOn ? true : (bool?)null);
AddIfPresent(config, "repositoryId", reviewerAgentOn ? context.AgentProfile!.RepositoryId!.Value.ToString() : null);
// S1: the reviewer clones at the launch's immutable base pin — the SAME commit the fan-out agents materialize,
Expand Down Expand Up @@ -253,17 +253,34 @@ private static JsonElement SynthConfig(TaskBuildContext context)
/// is the identical serialization of the identical array, so an ordinary run's prompt does not change by a
/// character; over budget the model is handed a fair share of every included branch and TOLD, in the prompt's
/// first sentence, that it is reading an excerpt.</para>
///
/// <para>A repo-bound graph's reduce is ALSO handed the integrate node's own account of what landed
/// (<see cref="IntegrationOutcomeSection"/>) and told what to do with it (<see cref="SynthIntegrationInstruction"/>).
/// The integrate step's outputs used to reach only <see cref="DoneInputs"/>: nothing the reduce reads said that a
/// candidate conflicted, or that a unit whose own check rejected it (or its output review did not approve it) was
/// withheld from it, so a partial integration was narrated as a whole deliverable. Whether the graph integrates is a BUILD-time fact (the integrate node exists
/// or it does not), so the conditional is legitimate here — and a repo-less graph keeps both halves of its prompt
/// byte-for-byte. The account itself is a RUN-time fact the node renders; the prompt only binds it. It sits OUTSIDE
/// the map's <c>promptBudgetChars</c>, which bounds the results projection alone — the node bounds the account
/// instead (<c>RunIntegrationSummary.MaxChars</c>, 2,000 characters against a 120,000-character default budget).</para>
/// </summary>
private static JsonElement SynthInputs(TaskBuildContext context) => JsonSerializer.SerializeToElement(new
private static JsonElement SynthInputs(TaskBuildContext context)
{
systemPrompt = SynthSystemPrompt,
// BYTE-IDENTICAL to the pre-continue prompt: the data half of the reduce carries the goal and the results,
// and nothing else. The failure half rides the SYSTEM prompt instead, so a run in which nothing failed is
// never handed a "Subtasks that failed: 0" line — the reduce learns about a failure from the marker actually
// sitting in its results, which is the only place the fact exists per-run. (A build-time conditional cannot
// express this: the failure count is a RUN-time fact and the prompt is frozen into the definition.)
userPrompt = $"Goal: {context.Seed.Goal}\n\nPer-subtask results:\n" + SynthResultsRef,
});
var integrates = IsRepoBound(context);

return JsonSerializer.SerializeToElement(new
{
systemPrompt = integrates ? SynthSystemPrompt + SynthIntegrationInstruction : SynthSystemPrompt,
// BYTE-IDENTICAL to the pre-continue prompt on a repo-less graph: the data half of the reduce carries the
// goal and the results, and nothing else. The failure half rides the SYSTEM prompt instead, so a run in
// which nothing failed is never handed a "Subtasks that failed: 0" line — the reduce learns about a failure
// from the marker actually sitting in its results, which is the only place the fact exists per-run. (A
// build-time conditional cannot express this: the failure count is a RUN-time fact and the prompt is frozen
// into the definition.) A repo-bound graph also carries what landed — on a clean run ONE factual sentence —
// because that is the fact the reduce needs in order to call the work delivered, not failure furniture.
userPrompt = $"Goal: {context.Seed.Goal}\n\nPer-subtask results:\n" + SynthResultsRef + (integrates ? IntegrationOutcomeSection : ""),
});
}

/// <summary>
/// The reduce's instruction. The failure clause is what continue-on-error requires of it: the run now reaches
Expand All @@ -278,6 +295,25 @@ private static JsonElement SynthInputs(TaskBuildContext context) => JsonSerializ
+ "A subtask that FAILED appears in the results as an {\"error\": ...} entry instead of a result: never present its work as done — "
+ "say which subtasks failed, what they were meant to deliver, and what is therefore missing from the answer.";

/// <summary>
/// What a REPO-BOUND reduce is told about the integration outcome it is shown, appended to
/// <see cref="SynthSystemPrompt"/> only when the graph has an integrate node — a repo-less reduce has no such
/// outcome, and a sentence about one would only invite the model to invent it. The first sentence carries the
/// honesty contract: what landed is stated as delivered, and anything conflicted or withheld is named as NOT
/// delivered. The second tells the reduce how to read a label: the account names units by an engine coordinate it
/// cannot place, and the same subtask also sits in the results it was handed (often as its <c>{"error": ...}</c>
/// entry), so it is taught the mapping and told to report each subtask once instead of once per section.
/// </summary>
internal const string SynthIntegrationInstruction =
" The integration outcome tells you what actually landed on the integrated branch; state what landed, "
+ "and name anything conflicted or withheld as NOT delivered — never narrate withheld work as done. "
+ "A label in it reads <node>#map#<i>, with i the 0-based position of that subtask in the results (\"subtask N\" is i+1); "
+ "a subtask named as withheld, or as landed although it failed, is usually one of those results' {\"error\": ...} entries and a conflicted one an ordinary result, "
+ "so report each subtask once, with everything known about it.";

/// <summary>The data half's integration section (repo-bound graphs only): the integrate node's own rendering of what landed, bound whole into the prompt. The key is one <c>git.integrate_run</c> declares in its OutputSchema, which <c>DefinitionValidator</c> enforces at build.</summary>
private const string IntegrationOutcomeSection = "\n\nIntegration outcome:\n{{nodes.integrate.outputs.summary}}";

/// <summary>The reduce's results binding, composed from <see cref="WorkflowOutputKeys.MapResultsPrompt"/> so the prompt and the key the reducer writes cannot drift apart.</summary>
private const string SynthResultsRef = "{{nodes.map.outputs." + WorkflowOutputKeys.MapResultsPrompt + "}}";

Expand Down Expand Up @@ -316,7 +352,7 @@ private static JsonElement DoneInputs(TaskBuildContext context)
[WorkflowOutputKeys.MapFailed] = SynthFailedRef,
};

if (context.AgentProfile?.RepositoryId is not null)
if (IsRepoBound(context))
{
inputs["integrationStatus"] = "{{nodes.integrate.outputs.status}}";
inputs["integratedBranch"] = "{{nodes.integrate.outputs.integratedBranch}}";
Expand All @@ -325,6 +361,9 @@ private static JsonElement DoneInputs(TaskBuildContext context)
return JsonSerializer.SerializeToElement(inputs);
}

/// <summary>Whether the launch binds a repository — the one fact that adds the integrate step (its node and edges), surfaces the candidate on the done terminal, and hands the reduce the integration outcome.</summary>
private static bool IsRepoBound(TaskBuildContext context) => context.AgentProfile?.RepositoryId is not null;

protected static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;

protected static void AddIfPresent(Dictionary<string, object?> bag, string key, object? value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,12 +156,14 @@ private static string BuildIntegrationBranch(NodeRunContext context) =>
["appliedCount"] = JsonSerializer.SerializeToElement(result.AppliedCount),
["reason"] = JsonSerializer.SerializeToElement(result.Reason),
["conflicts"] = JsonSerializer.SerializeToElement(
result.Outcomes
.Where(o => o.Disposition != ContributionDisposition.Applied)
.OrderBy(o => o.Skipped)
NotApplied(result)
.Select(o => new { label = o.Label, disposition = o.Disposition.ToString(), reason = o.Reason, conflictedFiles = o.ConflictedFiles, fallbackBranch = o.FallbackBranch, skipped = o.Skipped })),
};

/// <summary>The outcomes that did NOT apply, a real failure before a bystander (stable — ties keep outcome order). The one ordering <c>conflicts[]</c> above and <c>git.integrate_run</c>'s prose summary both read, so what a reader is told first never depends on which of the two it reads.</summary>
internal static IEnumerable<ContributionOutcome> NotApplied(IntegrationResult result) =>
result.Outcomes.Where(o => o.Disposition != ContributionDisposition.Applied).OrderBy(o => o.Skipped);

private static IReadOnlyList<BranchContribution> ReadContributions(NodeRunContext context)
{
if (!context.Inputs.TryGetValue("contributions", out var value) || value.ValueKind != JsonValueKind.Array)
Expand Down
Loading
Loading