| 1 | package agent |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "context" |
| 6 | "encoding/json" |
| 7 | "errors" |
| 8 | "fmt" |
| 9 | "io" |
| 10 | "strings" |
| 11 | "sync" |
| 12 | "time" |
| 13 | |
| 14 | "reasonix/internal/event" |
| 15 | "reasonix/internal/evidence" |
| 16 | "reasonix/internal/jobs" |
| 17 | "reasonix/internal/tool" |
| 18 | ) |
| 19 | |
| 20 | const ( |
| 21 | fleetMinTasks = 2 |
| 22 | fleetMaxTasks = 64 |
| 23 | ) |
| 24 | |
| 25 | // FleetTool dispatches multiple profile-aware sub-agent tasks in parallel |
| 26 | // under the session scheduler. Write tasks must predeclare non-overlapping |
| 27 | // write_paths; preflight failure starts nothing. |
| 28 | type FleetTool struct { |
| 29 | taskTool *TaskTool |
| 30 | } |
| 31 | |
| 32 | // NewFleetTool creates a fleet dispatcher that reuses TaskTool infrastructure. |
| 33 | func NewFleetTool(taskTool *TaskTool) *FleetTool { |
| 34 | return &FleetTool{taskTool: taskTool} |
| 35 | } |
| 36 | |
| 37 | func (*FleetTool) Name() string { return tool.HostFleet } |
| 38 | |
| 39 | func (*FleetTool) Description() string { |
| 40 | return "Dispatch 2–64 sub-agent tasks as a small dependency graph and return bounded previews plus stable Subagent references for full-result retrieval from completed persisted children with read_subagent_result. Each item may select a profile, model, effort, tools, write_paths, or read_only, and may declare depends_on to run after other items (research → implement → review). Tasks with no dependency between them run in parallel and must declare non-overlapping write_paths; ordered tasks may share paths. Omitted write_paths claim the whole workspace, so two or more concurrent writers without paths fail preflight before any task starts. A failed task's dependents are skipped; independent branches keep going unless fail_fast is set. Background mode returns a fleet job id collectable with job_output." |
| 41 | } |
| 42 | |
| 43 | func (*FleetTool) Schema() json.RawMessage { |
| 44 | return json.RawMessage(`{ |
| 45 | "type":"object", |
| 46 | "properties":{ |
| 47 | "tasks":{ |
| 48 | "type":"array", |
| 49 | "description":"Array of 2–64 sub-tasks to run under the session scheduler.", |
| 50 | "minItems":2, |
| 51 | "maxItems":64, |
| 52 | "items":{ |
| 53 | "type":"object", |
| 54 | "properties":{ |
| 55 | "prompt":{"type":"string","description":"Task prompt for the sub-agent."}, |
| 56 | "id":{"type":"string","description":"Optional stable id for this task, referenced by other tasks' depends_on. Defaults to the 1-based position."}, |
| 57 | "depends_on":{"type":"array","items":{"type":"string"},"description":"Ids of tasks that must complete before this one starts. Unknown ids, self-edges, and cycles fail preflight. A task whose dependency fails or is skipped is skipped too. Ordered tasks may share write_paths; only tasks that can run at the same time need disjoint claims."}, |
| 58 | "description":{"type":"string","description":"Optional short label shown in the job list."}, |
| 59 | "profile":{"type":"string","description":"Optional runAs=subagent profile name."}, |
| 60 | "write_paths":{"type":"array","items":{"type":"string"},"description":"Write targets for this item. Writers that can run at the same time must declare non-overlapping paths; writers ordered by depends_on may share them. Omitting write_paths claims the whole workspace; two concurrent whole-workspace claims (or any overlap between concurrent writers) fail preflight and start nothing."}, |
| 61 | "read_only":{"type":"boolean","description":"Force the read-only registry even if the profile is writable."}, |
| 62 | "tools":{"type":"array","items":{"type":"string"},"description":"Optional tool whitelist (intersected with profile allowed-tools)."}, |
| 63 | "max_steps":{"type":"integer","description":"Optional max tool-call rounds.","minimum":1}, |
| 64 | "model":{"type":"string","description":"Optional model override."}, |
| 65 | "effort":{"type":"string","description":"Optional reasoning effort override."} |
| 66 | }, |
| 67 | "required":["prompt"] |
| 68 | } |
| 69 | }, |
| 70 | "fail_fast":{"type":"boolean","description":"Stop starting new tasks after the first failure. Tasks already running are left to finish so partial writes are not abandoned mid-flight. Omitted (the default) means independent branches keep going; a failed task's dependents are skipped either way."}, |
| 71 | "run_in_background":{"type":"boolean","description":"Run the whole fleet asynchronously and return a job id collectable with job_output. Items queue for concurrency/write slots inside the job."} |
| 72 | }, |
| 73 | "required":["tasks"] |
| 74 | }`) |
| 75 | } |
| 76 | |
| 77 | func (*FleetTool) ReadOnly() bool { return false } |
| 78 | |
| 79 | type fleetTaskItem struct { |
| 80 | Prompt string `json:"prompt"` |
| 81 | ID string `json:"id"` |
| 82 | DependsOn []string `json:"depends_on"` |
| 83 | Description string `json:"description"` |
| 84 | Profile string `json:"profile"` |
| 85 | WritePaths []string `json:"write_paths"` |
| 86 | ReadOnly bool `json:"read_only"` |
| 87 | Tools []string `json:"tools"` |
| 88 | MaxSteps int `json:"max_steps"` |
| 89 | Model string `json:"model"` |
| 90 | Effort string `json:"effort"` |
| 91 | } |
| 92 | |
| 93 | type fleetItemStatus string |
| 94 | |
| 95 | const ( |
| 96 | fleetItemPending fleetItemStatus = "pending" |
| 97 | fleetItemCompleted fleetItemStatus = "completed" |
| 98 | fleetItemFailed fleetItemStatus = "failed" |
| 99 | fleetItemCancelled fleetItemStatus = "cancelled" |
| 100 | fleetItemSkipped fleetItemStatus = "skipped" |
| 101 | ) |
| 102 | |
| 103 | type fleetItemResult struct { |
| 104 | index int |
| 105 | status fleetItemStatus |
| 106 | profile string |
| 107 | output string |
| 108 | err error |
| 109 | ref string |
| 110 | } |
| 111 | |
| 112 | // fleetGroupTerminalPhase classifies a fleet group's single terminal status: |
| 113 | // cancellation/deadline wins, then any failed child, then any error |
| 114 | // (including validation failures), then completed. |
| 115 | func fleetGroupTerminalPhase(ctx context.Context, err error, results []fleetItemResult) subagentProgressPhase { |
| 116 | if ctx.Err() != nil { |
| 117 | return subagentPhaseCancelled |
| 118 | } |
| 119 | for _, r := range results { |
| 120 | if r.status == fleetItemFailed { |
| 121 | return subagentPhaseFailed |
| 122 | } |
| 123 | } |
| 124 | if err != nil { |
| 125 | return subagentPhaseFailed |
| 126 | } |
| 127 | return subagentPhaseCompleted |
| 128 | } |
| 129 | |
| 130 | func (f *FleetTool) Execute(ctx context.Context, args json.RawMessage) (result string, err error) { |
| 131 | if f == nil || f.taskTool == nil { |
| 132 | return "", fmt.Errorf("fleet is not configured") |
| 133 | } |
| 134 | // Group lifecycle: the group card's terminal is an explicit event from |
| 135 | // the tool (running once children start, exactly one terminal at the |
| 136 | // end) so frontends never infer group completion from the children they |
| 137 | // happen to have observed. Validation failures emit a failed terminal; |
| 138 | // once runFleet starts it owns the lifecycle (the background job runs |
| 139 | // runFleet inside the job, after this function has returned). |
| 140 | groupParentID, groupSink, _, ok := CallContext(ctx) |
| 141 | if !ok || groupSink == nil { |
| 142 | groupParentID = "fleet" |
| 143 | groupSink = event.Discard |
| 144 | } |
| 145 | // The merger emits already-namespaced group/child IDs, so it must use the |
| 146 | // raw call sink. A nested subSink would prefix the group ID a second time |
| 147 | // (group/group), leaving the frontend unable to match its lifecycle card. |
| 148 | merger := newSubagentProgressMerger(realProgressClock{}, groupSink, groupParentID) |
| 149 | lifecycleHandoff := false |
| 150 | mergerCloseHandoff := false |
| 151 | defer func() { |
| 152 | if !mergerCloseHandoff { |
| 153 | merger.Close() |
| 154 | } |
| 155 | }() |
| 156 | defer func() { |
| 157 | if lifecycleHandoff { |
| 158 | return |
| 159 | } |
| 160 | merger.directStatus(groupParentID, fleetGroupTerminalPhase(ctx, err, nil)) |
| 161 | }() |
| 162 | ctx = withSubagentProgressMerger(ctx, merger) |
| 163 | |
| 164 | var params struct { |
| 165 | Tasks []fleetTaskItem `json:"tasks"` |
| 166 | FailFast bool `json:"fail_fast"` |
| 167 | RunInBackground bool `json:"run_in_background"` |
| 168 | } |
| 169 | dec := json.NewDecoder(bytes.NewReader(args)) |
| 170 | dec.DisallowUnknownFields() |
| 171 | if err := dec.Decode(¶ms); err != nil { |
| 172 | return "", fmt.Errorf("invalid args: %w", err) |
| 173 | } |
| 174 | if n := len(params.Tasks); n < fleetMinTasks || n > fleetMaxTasks { |
| 175 | return "", fmt.Errorf("fleet requires between %d and %d tasks (got %d)", fleetMinTasks, fleetMaxTasks, n) |
| 176 | } |
| 177 | |
| 178 | specs := make([]ProfileExecSpec, len(params.Tasks)) |
| 179 | // Keep one claim slot per original task so preflight errors report the |
| 180 | // caller-visible task numbers even when read-only items are interleaved. |
| 181 | claims := make([]WritePathSet, len(params.Tasks)) |
| 182 | for i, item := range params.Tasks { |
| 183 | if strings.TrimSpace(item.Prompt) == "" { |
| 184 | return "", fmt.Errorf("task %d: prompt is required", i+1) |
| 185 | } |
| 186 | // Fleet writers without write_paths claim the whole workspace so the |
| 187 | // preflight can detect multi-writer collisions before anything starts. |
| 188 | forceBackgroundClaim := !item.ReadOnly |
| 189 | spec, err := f.taskTool.buildTaskSpec(ctx, item.Prompt, item.Description, item.Profile, item.WritePaths, item.Tools, item.MaxSteps, item.Model, item.Effort, "", "", false, item.ReadOnly) |
| 190 | if err != nil { |
| 191 | return "", fmt.Errorf("task %d: %w", i+1, err) |
| 192 | } |
| 193 | if forceBackgroundClaim && !spec.Grant.ReadOnly && spec.Grant.WritePaths.Empty() { |
| 194 | whole, werr := WholeWorkspaceWriteClaim(f.taskTool.workspaceRoot) |
| 195 | if werr != nil { |
| 196 | return "", fmt.Errorf("task %d: %w", i+1, werr) |
| 197 | } |
| 198 | spec.Grant.WritePaths = whole |
| 199 | } |
| 200 | spec.Sched.Nested = SubagentDepth(ctx) > 0 |
| 201 | spec.Sched.RunInBackground = false // fleet owns backgrounding |
| 202 | if spec.Task.Description == "" { |
| 203 | spec.Task.Description = fmt.Sprintf("fleet-%d", i+1) |
| 204 | } |
| 205 | specs[i] = spec |
| 206 | if !spec.Grant.ReadOnly { |
| 207 | claims[i] = spec.Grant.WritePaths |
| 208 | } |
| 209 | } |
| 210 | plan, err := newFleetPlan(params.Tasks, params.FailFast) |
| 211 | if err != nil { |
| 212 | return "", fmt.Errorf("fleet preflight: %w", err) |
| 213 | } |
| 214 | if err := plan.validateConcurrentWriteClaims(claims); err != nil { |
| 215 | return "", fmt.Errorf("fleet preflight: %w", err) |
| 216 | } |
| 217 | |
| 218 | if params.RunInBackground { |
| 219 | for i := range specs { |
| 220 | specs[i].Sched.BackgroundWriter = !specs[i].Grant.ReadOnly |
| 221 | } |
| 222 | jm, ok := jobs.FromContext(ctx) |
| 223 | if !ok { |
| 224 | return "", fmt.Errorf("background execution is not available in this context") |
| 225 | } |
| 226 | parentID := groupParentID |
| 227 | parentSession := ParentSession(ctx) |
| 228 | label := fmt.Sprintf("fleet(%d)", len(specs)) |
| 229 | backgroundEvidence := evidence.NewLedger() |
| 230 | writerID := fmt.Sprintf("background-fleet:%s:%d", parentID, time.Now().UnixNano()) |
| 231 | writerRegistered := false |
| 232 | observer := f.taskTool.mutationObserver |
| 233 | if observer != nil { |
| 234 | hasWriter := false |
| 235 | for i := range specs { |
| 236 | if specs[i].Sched.BackgroundWriter { |
| 237 | hasWriter = true |
| 238 | break |
| 239 | } |
| 240 | } |
| 241 | if hasWriter { |
| 242 | if err := observer.RegisterWriter(writerID, "background_fleet", observer.OwnershipTurn()); err != nil { |
| 243 | return "", err |
| 244 | } |
| 245 | writerRegistered = true |
| 246 | } |
| 247 | } |
| 248 | job := jm.StartForSession(jobs.SessionFromContext(ctx), "fleet", label, func(jobCtx context.Context, _ io.Writer) (string, error) { |
| 249 | // Execute returns as soon as the job is registered, so the job owns |
| 250 | // the handed-off merger until every child preview and terminal has |
| 251 | // flushed. Closing it in Execute would strand child cards at running. |
| 252 | defer merger.Close() |
| 253 | if writerRegistered { |
| 254 | defer observer.UnregisterWriter(writerID) |
| 255 | } |
| 256 | jobCtx = WithParentSession(jobCtx, parentSession) |
| 257 | jobCtx = evidence.WithLedger(jobCtx, backgroundEvidence) |
| 258 | defer publishBackgroundEvidence(jobCtx, backgroundEvidence, f.taskTool.workspaceRoot) |
| 259 | // The job shares the Execute-level merger so the group lifecycle |
| 260 | // events and the child previews ride the same pacing budget. |
| 261 | jobCtx = withSubagentProgressMerger(jobCtx, merger) |
| 262 | return f.runFleet(jobCtx, groupSink, specs, plan, parentID) |
| 263 | }) |
| 264 | // runFleet (inside the job) owns the terminal and merger close from |
| 265 | // here on. Foreground runFleet hands off only the terminal; Execute |
| 266 | // still closes the merger after the synchronous call returns. |
| 267 | lifecycleHandoff = true |
| 268 | mergerCloseHandoff = true |
| 269 | return fmt.Sprintf("Started background fleet %q (%s). Collect results with job_output; you will be notified when it finishes.", job.ID, label), nil |
| 270 | } |
| 271 | |
| 272 | lifecycleHandoff = true |
| 273 | return f.runFleet(ctx, groupSink, specs, plan, groupParentID) |
| 274 | } |
| 275 | |
| 276 | func (f *FleetTool) runFleet(ctx context.Context, sink event.Sink, specs []ProfileExecSpec, plan fleetPlan, groupParentID string) (result string, err error) { |
| 277 | if sink == nil { |
| 278 | sink = event.Discard |
| 279 | } |
| 280 | // Child IDs are namespaced exactly once under the group call. Background |
| 281 | // jobs no longer carry the original call context, so groupParentID is the |
| 282 | // authoritative identity there; direct callers fall back to CallContext. |
| 283 | parentID := strings.TrimSpace(groupParentID) |
| 284 | if parentID == "" { |
| 285 | var ok bool |
| 286 | parentID, _, _, ok = CallContext(ctx) |
| 287 | if !ok || parentID == "" { |
| 288 | parentID = "fleet" |
| 289 | } |
| 290 | } |
| 291 | groupParentID = parentID |
| 292 | // The Execute-level merger (or a fallback for direct callers) paces the |
| 293 | // group; runFleet owns the lifecycle once it starts: running up front |
| 294 | // and exactly one terminal after every child settles. |
| 295 | merger := subagentProgressMergerFromContext(ctx) |
| 296 | ownsMerger := false |
| 297 | if merger == nil { |
| 298 | merger = newSubagentProgressMerger(realProgressClock{}, sink, groupParentID) |
| 299 | ownsMerger = true |
| 300 | ctx = withSubagentProgressMerger(ctx, merger) |
| 301 | } |
| 302 | if ownsMerger { |
| 303 | defer merger.Close() |
| 304 | } |
| 305 | merger.directStatus(groupParentID, subagentPhaseRunning) |
| 306 | var results []fleetItemResult |
| 307 | defer func() { |
| 308 | merger.directStatus(groupParentID, fleetGroupTerminalPhase(ctx, err, results)) |
| 309 | }() |
| 310 | |
| 311 | n := len(specs) |
| 312 | results = make([]fleetItemResult, n) |
| 313 | for i := range results { |
| 314 | results[i] = fleetItemResult{index: i, status: fleetItemPending, profile: specs[i].Worker.Profile} |
| 315 | } |
| 316 | |
| 317 | var wg sync.WaitGroup |
| 318 | doneCh := make(chan fleetItemResult, n) |
| 319 | |
| 320 | startOne := func(idx int) { |
| 321 | spec := specs[idx] |
| 322 | label := spec.Task.Description |
| 323 | subID := fmt.Sprintf("%s/fleet-%d", parentID, idx+1) |
| 324 | dispatchArgs, _ := json.Marshal(map[string]any{ |
| 325 | "prompt": spec.Task.Objective, |
| 326 | "description": label, |
| 327 | "profile": spec.Worker.Profile, |
| 328 | }) |
| 329 | sink.Emit(event.Event{ |
| 330 | Kind: event.ToolDispatch, |
| 331 | Tool: event.Tool{ |
| 332 | ID: subID, ParentID: parentID, Name: "task", |
| 333 | Args: string(dispatchArgs), ReadOnly: spec.Grant.ReadOnly, |
| 334 | }, |
| 335 | }) |
| 336 | |
| 337 | wg.Go(func() { |
| 338 | // Each fleet item runs as its own task-shaped execution so |
| 339 | // transcripts, evidence, and scheduler claims stay independent. |
| 340 | itemCtx := withCallContext(ctx, subID, subSinkFor(subID, sink), nil, false) |
| 341 | out, err := f.taskTool.RunProfileSpec(itemCtx, spec) |
| 342 | answer, ref := splitSubagentRunResult(out) |
| 343 | res := fleetItemResult{index: idx, profile: spec.Worker.Profile, output: answer, ref: ref, err: err} |
| 344 | if err == nil { |
| 345 | res.status = fleetItemCompleted |
| 346 | sink.Emit(event.Event{ |
| 347 | Kind: event.ToolResult, |
| 348 | Tool: event.Tool{ID: subID, ParentID: parentID, Name: "task", Output: out}, |
| 349 | }) |
| 350 | } else { |
| 351 | if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { |
| 352 | res.status = fleetItemCancelled |
| 353 | } else { |
| 354 | res.status = fleetItemFailed |
| 355 | } |
| 356 | sink.Emit(event.Event{ |
| 357 | Kind: event.ToolResult, |
| 358 | Tool: event.Tool{ID: subID, ParentID: parentID, Name: "task", Err: err.Error()}, |
| 359 | }) |
| 360 | } |
| 361 | doneCh <- res |
| 362 | }) |
| 363 | } |
| 364 | |
| 365 | cancelled := driveFleet(ctx, plan, results, doneCh, wg.Wait, startOne) |
| 366 | for _, r := range results { |
| 367 | if r.status == fleetItemCancelled || r.status == fleetItemSkipped { |
| 368 | cancelled = true |
| 369 | break |
| 370 | } |
| 371 | } |
| 372 | if cancelled { |
| 373 | err := ctx.Err() |
| 374 | if err == nil { |
| 375 | err = context.Canceled |
| 376 | } |
| 377 | return formatFleetAggregate(results, true), err |
| 378 | } |
| 379 | return formatFleetAggregate(results, false), nil |
| 380 | } |
| 381 | |
| 382 | func formatFleetAggregate(results []fleetItemResult, cancelled bool) string { |
| 383 | n := len(results) |
| 384 | var prefix string |
| 385 | if cancelled { |
| 386 | completed := 0 |
| 387 | for _, r := range results { |
| 388 | if r.status == fleetItemCompleted { |
| 389 | completed++ |
| 390 | } |
| 391 | } |
| 392 | prefix = fmt.Sprintf("Cancelled fleet after completing %d of %d tasks:\n", completed, n) |
| 393 | } else { |
| 394 | prefix = fmt.Sprintf("Completed fleet of %d tasks:\n", n) |
| 395 | } |
| 396 | items := make([]subagentAggregateItem, 0, n) |
| 397 | for i, r := range results { |
| 398 | header := fmt.Sprintf("── task-%d", i+1) |
| 399 | if r.profile != "" { |
| 400 | header += " profile=" + boundedInline(r.profile, 80) |
| 401 | } |
| 402 | header += " ──\n" |
| 403 | item := subagentAggregateItem{header: header, ref: r.ref} |
| 404 | switch r.status { |
| 405 | case fleetItemCompleted: |
| 406 | item.status = "status: completed\n" |
| 407 | item.answer = strings.TrimSpace(r.output) |
| 408 | case fleetItemFailed: |
| 409 | item.status = "status: failed\n" |
| 410 | if r.err != nil { |
| 411 | item.detail = fmt.Sprintf("[FAILED] %s\n", boundedInline(r.err.Error(), 256)) |
| 412 | } |
| 413 | case fleetItemCancelled: |
| 414 | item.status = "status: cancelled\n" |
| 415 | if r.err != nil { |
| 416 | item.detail = fmt.Sprintf("[CANCELLED] %s\n", boundedInline(r.err.Error(), 256)) |
| 417 | } |
| 418 | case fleetItemSkipped: |
| 419 | item.status = "status: skipped\n" |
| 420 | if r.err != nil { |
| 421 | item.detail = fmt.Sprintf("[SKIPPED] %s\n", boundedInline(r.err.Error(), 256)) |
| 422 | } |
| 423 | default: |
| 424 | item.status = "status: pending\n" |
| 425 | } |
| 426 | items = append(items, item) |
| 427 | } |
| 428 | return formatBoundedSubagentAggregate(prefix, items) |
| 429 | } |
| 430 |