M4: the assembler — assemble → model → parse (Increment 2)

SharedKernel contracts (so Assembler stays decoupled): IAgentRunContextProvider (agent +
task) and ISkillCatalog (skill prompts by key). Implemented by OrgBoard (AgentRunContextProvider)
and Skills (SkillCatalog).

Assembler:
- PromptAssembler builds house-style + identity + the agent's skill bodies + the task, and
  derives the primary action + risk from the agent's first skill. RAG/working-memory join at M6.
- AgentRunExecutor (real): resolve context + skills → assemble → resolve BYOK config (with
  fallback) → call IModelClient → parse into action + risk → capture all on the AgentRun.

Verified: build green; ArchitectureTests 8/8; IntegrationTests 29/29 — incl. the M4 acceptance:
assigning a Spec task to Aria (PO, gated, stub BYOK) yields a Completed run with the assembled
prompt (skill body + task title), action "write-spec", risk "Draft", and model output. Nothing
executes — the gate is M5.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-10 06:19:02 +03:30
parent 09eaf360a3
commit d9f9349117
10 changed files with 388 additions and 16 deletions
@@ -3,43 +3,78 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using TeamUp.Modules.Assembler.Domain;
using TeamUp.Modules.Assembler.Persistence;
using TeamUp.SharedKernel.Ai;
namespace TeamUp.Modules.Assembler.Runtime;
internal sealed record AgentRunPayload(Guid RunId);
/// <summary>
/// Processes one claimed job: drives the AgentRun lifecycle. In M4 Increment 1 it records a
/// placeholder; Increment 2 swaps the middle for the real assembler (assemble → model → parse).
/// Processes one claimed job end to end: resolve the run context (OrgBoard) + skills (Skills) →
/// assemble the prompt → call the model (BYOK, with fallback) → parse into an action + risk tag,
/// all captured on the AgentRun. Nothing executes off the parsed action — the gate is M5.
/// </summary>
internal sealed class AgentRunExecutor(AssemblerDbContext db, TimeProvider clock, ILogger<AgentRunExecutor> logger)
internal sealed class AgentRunExecutor(
AssemblerDbContext db,
IAgentRunContextProvider contextProvider,
ISkillCatalog skillCatalog,
IApiConfigResolver configResolver,
IModelClient modelClient,
TimeProvider clock,
ILogger<AgentRunExecutor> logger)
{
public async Task ProcessAsync(Job job, CancellationToken cancellationToken = default)
{
AgentRun? run = null;
try
{
var payload = JsonSerializer.Deserialize<AgentRunPayload>(job.Payload)
?? throw new InvalidOperationException("Invalid job payload.");
var run = await db.AgentRuns.FirstOrDefaultAsync(r => r.Id == payload.RunId, cancellationToken)
run = await db.AgentRuns.FirstOrDefaultAsync(r => r.Id == payload.RunId, cancellationToken)
?? throw new InvalidOperationException($"AgentRun {payload.RunId} not found.");
run.Start(agentId: null, prompt: "[assembler pending — M4 Increment 2]", trace: null);
var context = await contextProvider.GetAsync(run.SeatId, run.WorkItemId, cancellationToken)
?? throw new InvalidOperationException("Agent or task not found for the run.");
var skills = await skillCatalog.GetByKeysAsync(context.SkillKeys, cancellationToken);
var assembled = PromptAssembler.Build(context, skills);
run.Start(context.AgentId, assembled.Prompt, assembled.Trace);
await db.SaveChangesAsync(cancellationToken);
// TODO (M4 Increment 2): assemble the prompt, call the model, parse into action + risk.
run.Complete(
output: "[assembler pending]",
actionType: "pending",
actionRisk: "read",
resultJson: null,
latencyMs: 0,
clock.GetUtcNow());
var config = await configResolver.ResolveAsync(context.ApiConfigId, cancellationToken)
?? (context.FallbackApiConfigId is { } fallback
? await configResolver.ResolveAsync(fallback, cancellationToken)
: null)
?? throw new InvalidOperationException("No usable model config for the agent.");
var completion = await modelClient.CompleteAsync(
new ModelRequest(config.Provider, config.Model, config.ApiKey, config.Endpoint, assembled.Prompt, MaxTokens: 512),
cancellationToken);
if (!completion.Success)
{
var error = completion.Error ?? "Model call failed.";
run.Fail(error, clock.GetUtcNow());
job.MarkFailed(error, clock.GetUtcNow());
await db.SaveChangesAsync(cancellationToken);
return;
}
var result = JsonSerializer.Serialize(new
{
action = assembled.PrimaryAction,
risk = assembled.PrimaryActionRisk,
skill = context.SkillKeys.Count > 0 ? context.SkillKeys[0] : null,
});
run.Complete(completion.Text ?? string.Empty, assembled.PrimaryAction, assembled.PrimaryActionRisk, result, completion.LatencyMs, clock.GetUtcNow());
job.MarkDone(clock.GetUtcNow());
await db.SaveChangesAsync(cancellationToken);
}
catch (Exception ex)
{
run?.Fail(ex.Message, clock.GetUtcNow());
job.MarkFailed(ex.Message, clock.GetUtcNow());
await db.SaveChangesAsync(cancellationToken);
logger.LogError(ex, "Agent-run job {JobId} failed.", job.Id);