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
4 changes: 4 additions & 0 deletions docs/LAB.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,10 @@ machine's own git credentials. Every other request (spec generation, the 5-agent
answer-only. It is still code from a ticket running on your box; the machine's owner can refuse scoped runs
with `REMOTE_AGENT_AUTONOMY=off`. With the runner paired, `RUN_AGENTOS_E2E_REAL=true AGENTOS_REAL_URL=https://lab.dev.localhost:6180 AGENTOS_E2E_RUN_TIMEOUT_SECONDS=1200 dotnet test --project tests/AgentOs.E2E.Tests --filter-class "*WorkflowAppRealAuthTests"` runs the full 5-agent pipeline live.

The runner keeps two lanes: work runs (scoped: clone, build, push — `REMOTE_AGENT_MAX_PARALLEL`, default 2) and
answer-only runs such as a New idea spec or ticket split (`REMOTE_AGENT_MAX_ANSWERS`, default 2), so a person waiting on
the screen never queues behind Autopilot's coding. Specialist answers get a 5-minute deadline (`LlmRequest.AgentAnswerTimeout`).

### Try the app on the lab

With the runner paired, Board → VNChessAcademy → **Try the app** on a repository runs its latest default branch on
Expand Down
6 changes: 6 additions & 0 deletions src/AgentOs.Domain/Llm/LlmRequest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ public sealed record LlmRequest(
System.TimeSpan? Timeout = null,
string? Cli = null)
{
/// <summary>
/// Deadline for one specialist answer (a spec, a ticket split, a review) on a paired computer: a CLI answer takes a
/// minute or two, more on a busy machine. Above the 120 s dispatch default, far below a coding run's budget.
/// </summary>
public static readonly System.TimeSpan AgentAnswerTimeout = System.TimeSpan.FromMinutes(5);

/// <summary>
/// Optional canonical agent role behind this request (<c>"Requirement"</c>/<c>"Coding"</c>/<c>"Testing"</c>/
/// <c>"Qa"</c>), set by the specialist agents. Lets a provider identify the agent WITHOUT parsing the system
Expand Down
1 change: 1 addition & 0 deletions src/AgentOs.Modules.Pipeline/Agents/LlmAgentBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,7 @@ protected async Task<TResult> ExecuteAsync<TDto, TResult>(
// Carry the fixed agent role out-of-band so providers (notably the offline one) can identify the
// agent without parsing the system prompt — robust against per-tenant prompt overrides.
AgentRole = PromptKey,
Timeout = LlmRequest.AgentAnswerTimeout,
};
var response = await llm.SendAsync(request, ct).ConfigureAwait(false);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,12 @@ namespace AgentOs.Modules.Pipeline.Decomposition;
/// <summary>A generated requirement spec plus the tickets decomposed from it — the wizard's preview.</summary>
/// <param name="Spec">The structured requirement the idea produced.</param>
/// <param name="Tickets">The right-sized tickets to create (still editable before the user confirms).</param>
public sealed record BootstrapPreview(RequirementSpec Spec, IReadOnlyList<TicketDraft> Tickets);
public sealed record BootstrapPreview(RequirementSpec Spec, IReadOnlyList<TicketDraft> Tickets)
{
/// <summary>False when the Decomposition agent couldn't refine the tickets (unreachable, timed out, unreadable answer)
/// and these are the deterministic drafts — one per requirement line. The wizard says so and offers to try again.</summary>
public bool Refined { get; init; } = true;
}

/// <summary>Turns a free-text idea into a reviewable spec + ticket set (no side effects).</summary>
public interface IIdeaBootstrapService
Expand Down Expand Up @@ -48,6 +53,7 @@ public async Task<BootstrapPreview> GenerateAsync(string idea, string locale = "
var seed = TicketMapper.Map(spec);
var tickets = await _decomposer.RunAsync(spec, seed, idea, cancellationToken).ConfigureAwait(false);

return new BootstrapPreview(spec, tickets);
// The decomposer hands back the very seed list when it couldn't refine it.
return new BootstrapPreview(spec, tickets) { Refined = !ReferenceEquals(tickets, seed) };
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,8 @@ public async Task<IReadOnlyList<TicketDraft>> RunAsync(RequirementSpec spec, IRe
UserPrompt: DecomposerPrompt.RenderUser(spec, seed, idea),
Model: profile.Model,
Temperature: profile.Temperature,
MaxTokens: profile.MaxTokens);
MaxTokens: profile.MaxTokens,
Timeout: LlmRequest.AgentAnswerTimeout);

try
{
Expand Down
14 changes: 11 additions & 3 deletions src/AgentOs.RemoteAgent/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
// REMOTE_AGENT_ALLOW shell allowlist for server-driven tool calls (see RunnerCommandPolicy): a comma list
// replaces the defaults, "+name" adds, "*" allows everything (explicit opt-out)
// REMOTE_AGENT_TIMEOUT_SECONDS per shell command (default 600); on expiry the whole process tree is killed
// REMOTE_AGENT_MAX_PARALLEL CLI runs at once on this machine (default 2); more wait their turn
// REMOTE_AGENT_MAX_PARALLEL work runs (scoped: clone, build, push) at once on this machine (default 2); more wait
// REMOTE_AGENT_MAX_ANSWERS answer-only runs (a spec, a ticket split — someone is waiting on screen) at once
// (default 2); their own lane, so they never queue behind a long work run
// REMOTE_AGENT_DATA test data the agents may read: "NAME=C:\path;OTHER=/data/x" → env vars of each scoped run,
// names (not paths) told to the agent, read-only to it (see TestData in ScopedAutonomy.cs)
// REMOTE_AGENT_PREVIEWS "off" refuses "Try the app" previews on this machine (default: allowed — the member clicks
Expand Down Expand Up @@ -107,14 +109,20 @@
// So handlers only enqueue; the work runs off the receive loop, at most REMOTE_AGENT_MAX_PARALLEL CLI runs at once.
var maxParallel = int.TryParse(Environment.GetEnvironmentVariable("REMOTE_AGENT_MAX_PARALLEL"), out var mp) && mp > 0 ? mp : 2;
var cliSlots = new SemaphoreSlim(maxParallel, maxParallel);
// Answer-only requests (no tools on this machine) are short and usually have a person waiting on them. Behind two
// 15-minute coding runs they outlived the server's deadline — the ticket split fell back to the raw draft list.
var maxAnswers = int.TryParse(Environment.GetEnvironmentVariable("REMOTE_AGENT_MAX_ANSWERS"), out var ma) && ma > 0 ? ma : 2;
var answerSlots = new SemaphoreSlim(maxAnswers, maxAnswers);

// M3 — full-prompt LLM dispatch
connection.On<RemoteExecRequest>("Execute", request =>
{
Console.WriteLine($"[agent] Execute {request.Id} (model={request.Model})");
var lane = AgentOs.RemoteAgent.ScopedAutonomy.Grants(request.Autonomy, Environment.GetEnvironmentVariable("REMOTE_AGENT_AUTONOMY"))
? cliSlots : answerSlots;
_ = Task.Run(async () =>
{
await cliSlots.WaitAsync().ConfigureAwait(false);
await lane.WaitAsync().ConfigureAwait(false);
try
{
// Stopped while it waited for a slot: never start it.
Expand All @@ -135,7 +143,7 @@
}
finally
{
cliSlots.Release();
lane.Release();
}
});
return Task.CompletedTask;
Expand Down
12 changes: 12 additions & 0 deletions src/AgentOs.Web/Components/Pages/BoardApp.razor
Original file line number Diff line number Diff line change
Expand Up @@ -461,6 +461,18 @@ else if (_tab == Tab.Idea)
<h3>@_preview.Spec.Title</h3>
<p class="muted">@_preview.Spec.Summary</p>
</div>
@if (!_preview.Refined)
{
<div class="board-failure" role="status">
<strong>The AI couldn't split this into tickets this time.</strong>
<span>These are rough drafts, one per requirement line — try again (your computer may have been busy), or edit and remove rows below.</span>
<div class="board-failure-actions">
<Btn Size="Btn.ButtonSize.Sm" Variant="Btn.ButtonVariant.Primary" OnClick="GenerateIdea" Disabled="_ideaBusy">
<Icon Name="arrow-clockwise" Size="13" /> @(_ideaBusy ? "Trying…" : "Try again")
</Btn>
</div>
</div>
}

<table class="data-table">
<thead>
Expand Down
18 changes: 18 additions & 0 deletions tests/AgentOs.Tests/Decomposition/IdeaBootstrapServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,24 @@ await requirement.Received(1).RunAsync(
// The decomposer is fed the deterministic seed (non-empty) built from the spec.
await decomposer.Received(1).RunAsync(
spec, Arg.Is<IReadOnlyList<TicketDraft>>(seed => seed!.Count > 0), "build a product catalog", Arg.Any<CancellationToken>());
preview.Refined.ShouldBeTrue();
}

[Fact]
public async Task GenerateAsync_DecomposerFellBackToSeed_IsNotRefined()
{
var spec = DecompositionFixtures.Spec();
var requirement = Substitute.For<IRequirementAgent>();
requirement.RunAsync(Arg.Any<UserStory>(), Arg.Any<CancellationToken>()).Returns(spec);
var decomposer = Substitute.For<ITicketDecomposerAgent>();
// The real agent returns the very seed list it was given when it can't refine it.
decomposer.RunAsync(spec, Arg.Any<IReadOnlyList<TicketDraft>>(), Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(ci => ci.ArgAt<IReadOnlyList<TicketDraft>>(1));

var preview = await new IdeaBootstrapService(requirement, decomposer).GenerateAsync("build a product catalog");

preview.Refined.ShouldBeFalse();
preview.Tickets.ShouldNotBeEmpty();
}

[Fact]
Expand Down
Loading