Implement delegation, so an agent's subagents are more than decoration
Some checks failed
CI / test (push) Has been cancelled
CI / fixture (push) Has been cancelled

krow-workforce-agent has declared five subagents since it was written and
answered every question by itself. Everything for §6 existed except the
delegation: the parser read `subagents:`, runtime.Agent carried them, the
loader populated them, agent_runs had a parent_run_id column with a
self-reference and a no-self-parent constraint, and budget.go's comments
already described sharing a budget with subagents. Nothing called any of it.

A subagent is offered to the parent's model as a tool, because §6 says that is
what delegation is from the parent's side. Three rules are enforced rather than
assumed, each with a test that fails if it stops holding:

  I1  The subagent runs as the ORIGINAL caller. It cannot read anything the
      person could not read directly.
  §6  It SHARES the parent's budget. The test sets MaxSteps to 1, spends it in
      the parent, and asserts the child terminates BudgetExceeded — an
      assertion that only passes when the budget is shared, and that a fresh
      budget would quietly turn green.
  §3  Depth is capped at 2. At the cap no subagent is loaded or offered, so a
      cycle reaching run time is bounded rather than unbounded.

I4 survives too: a write a SUBAGENT wants approved still stops the whole run
and asks a person, rather than being performed because it happened one level
down.

Two bugs found by running it rather than by reading it:

  - delegate() read the error before the result. finish returns a non-nil
    error for every termination that is not Completed, INCLUDING
    ConfirmationPending — which is not a failure but a run that stopped to ask
    a question. Reading the error first discarded the result and with it the
    confirmation, so a subagent's write silently never happened and nobody was
    asked.

  - Delegated trajectories were never persisted at all. parent_run_id is a
    foreign key and a subagent finishes BEFORE the run that delegated to it,
    so every child insert named a parent row that did not exist yet. The
    database refused it; finish deliberately does not fail a run over a sink
    error; and the entry recording that the trajectory could not be saved was
    itself in the trajectory that was not saved. Children are now buffered and
    written by finish after the parent's own row, each arriving with its
    descendants already ordered behind it, so one pass writes a whole tree
    parent-first. The regression test asserts on save ORDER, because a
    MemorySink has no foreign key and will pass either way.

Verified end to end against a live model: an agent with no tools of its own and
one subagent produced

    delegation-probe   run=run_16622d7de6  parent=(root)
    talent-pool-agent  run=run_64160684b1  parent=run_16622d7de6

with the subagent's answer reaching the parent's model. Full suite green, only
TestLive* skipped.

Not addressed: §3's publish-time cycle detection, which needs the whole agent
set in hand. The depth cap is what holds without it, and is the half that
matters at run time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PJvibeSc1JYXjatankqM1g
This commit is contained in:
2026-08-29 11:59:20 +05:30
parent a1e91f776d
commit 6849363a37
5 changed files with 747 additions and 3 deletions

View File

@@ -25,6 +25,11 @@ import (
// The loop runs until the model stops asking for tools, or until a bound is
// reached. Every exit is one of the six terminations.
type ModelExecutor struct {
// subagents resolves a spec's `subagents:` into runnable agents. nil means
// delegation is off and a spec that declares subagents runs alone — see
// delegate.go.
subagents SubagentResolver
gw gateway.Gateway
sink Sink
tools *tools.Registry
@@ -109,9 +114,29 @@ func (m *ModelExecutor) ExecuteAgent(ctx context.Context, agent *Agent, input Ex
// itself changing shape.
func (m *ModelExecutor) executeWithLimits(
ctx context.Context, agent *Agent, input ExecutionInput, limits Limits,
) (*ExecutionResult, error) {
return m.executeRun(ctx, agent, input, limits, delegation{})
}
// executeRun is the loop. `del` is what a SUBAGENT inherits from its parent —
// a shared budget, a parent run id, and a depth — and its zero value is an
// ordinary root run that owns its own budget and has no parent.
//
// One loop, not two. A delegated run is the same code on the same path; the
// only things that differ are where its budget came from and what its
// trajectory is linked to. §6 says there is exactly one loop, and a second one
// for subagents would be the first place the two drifted apart.
func (m *ModelExecutor) executeRun(
ctx context.Context, agent *Agent, input ExecutionInput, limits Limits, del delegation,
) (*ExecutionResult, error) {
tier, known := gateway.ParseTier(agent.Reasoning)
budget := NewBudget(limits)
// §6: a subagent SHARES the parent's budget and never gets a fresh one.
// Delegating would otherwise be a way to buy more steps.
budget := del.budget
if budget == nil {
budget = NewBudget(limits)
}
skillIDs := make([]string, len(agent.ResolvedSkills))
for i, s := range agent.ResolvedSkills {
@@ -120,6 +145,7 @@ func (m *ModelExecutor) executeWithLimits(
rec := NewRecorder(&Trajectory{
RunID: newRunID(),
ParentRunID: del.parentRunID,
OrgID: input.Identity.OrgID,
UserID: input.Identity.UserID,
AgentID: agent.ID,
@@ -163,6 +189,13 @@ func (m *ModelExecutor) executeWithLimits(
rec.Error("runtime.unknown_tool", fmt.Sprintf("%q is not a registered tool; it was not offered", name))
}
// Subagents are offered as tools, because from this agent's side that is
// exactly what they are (§6). Resolved once per run rather than per turn:
// the set cannot change mid-run, and loading it per turn would spend the
// caller's time on the same query repeatedly.
subs := m.resolveSubagents(ctx, rec, agent, input.Identity, del.depth)
toolDefs = append(toolDefs, delegateTools(subs)...)
// An approved write happens FIRST, before the model gets a turn.
//
// This is the half of I4 that makes a confirmation reliable rather than
@@ -259,7 +292,7 @@ func (m *ModelExecutor) executeWithLimits(
Role: gateway.RoleAssistant, Text: resp.Text, ToolCalls: resp.ToolCalls,
})
results, pending, term := m.runTools(runCtx, rec, budget, agent, input, resp.ToolCalls)
results, pending, term := m.runTools(runCtx, rec, budget, agent, input, resp.ToolCalls, subs, del.depth)
if term != "" {
return m.finish(ctx, rec, budget, term, agent, skillIDs, lastText, nil)
}
@@ -425,6 +458,7 @@ func (m *ModelExecutor) toolsFor(agent *Agent) (defs []gateway.ToolDef, unknown
func (m *ModelExecutor) runTools(
ctx context.Context, rec *Recorder, budget *Budget, agent *Agent,
input ExecutionInput, calls []gateway.ToolCall,
subs map[string]*Agent, depth int,
) (results []gateway.ToolResult, pending []*tools.Confirmation, term Termination) {
results = make([]gateway.ToolResult, 0, len(calls))
@@ -434,6 +468,40 @@ func (m *ModelExecutor) runTools(
}
rec.Budget(budget.Snapshot())
// A delegation spends the same tool-call budget as any other call,
// claimed above, and then spends the parent's remaining budget inside
// the subagent. It is charged twice on purpose: once for asking, and
// then for whatever the asking cost.
if sub, isDelegation := subs[call.Name]; isDelegation {
rec.ToolCall(call.Name, "delegate", json.RawMessage(call.Input))
answer, subPending := m.delegate(ctx, rec, budget, sub, input,
json.RawMessage(call.Input), depth)
encoded, err := json.Marshal(answer)
if err != nil {
encoded = []byte(`{"error":"the subagent's answer could not be encoded"}`)
}
rec.ToolResult(call.Name, "delegate", answer.Error != "",
tools.Result{Data: answer})
// I4 survives delegation. A write a SUBAGENT wants approved is
// still a write, and it stops this run the same way one from a
// direct tool call does — see the pending check in the loop.
if len(subPending) > 0 {
for _, c := range subPending {
rec.Confirmation(call.Name, c)
}
pending = append(pending, subPending...)
continue
}
results = append(results, gateway.ToolResult{
CallID: call.ID,
Content: string(encoded),
IsError: answer.Error != "",
})
continue
}
// The declared effect travels with the record. An eval asking "did this
// run change anything" reads it from here rather than keeping its own
// list of which tools write — a list that goes stale on the first tool
@@ -535,6 +603,17 @@ func (m *ModelExecutor) finish(
rec.Error("runtime.trajectory_unsaved", err.Error())
}
// Delegated runs are written AFTER this one, because parent_run_id is a
// foreign key and a subagent finishes first. Each child arrives with its
// own descendants already ordered behind it, so one pass here writes a
// whole tree parent-first.
for _, child := range rec.Children() {
if err := m.sink.Save(ctx, child); err != nil {
rec.Error("runtime.subrun_unsaved",
fmt.Sprintf("%s: %s", child.RunID, err.Error()))
}
}
res := &ExecutionResult{
Success: term == TerminationCompleted,
Output: output,