fix(agent): preserve parallel subagent results (#7375)

Problem: Long parallel_tasks and fleet answers could exceed the single-tool output limit, combining and truncating independent child reports.

Root cause: Both aggregators concatenated every full child answer before the global output guard applied head/tail truncation.

Fix: Persist each child transcript, return bounded fair previews with stable references, and add a conversation-scoped read_subagent_result pager with lineage and workspace checks.

Verification: go test ./... -count=1; go test -race ./internal/agent -count=1; ./scripts/cache-guard.sh; git diff --check.
This commit is contained in:
SivanCola authored and GitHub committed 2026-08-05 02:43:45 +08:00
1 parent 8f6015118e
commit 2c54501b5e
16 files changed
+619 -124

No files matched your search

+5
View File
@@ -69,6 +69,11 @@ branch.
### Fixed
- Fixed long parallel sub-agent research being silently lost when combined
`parallel_tasks` or `fleet` answers exceeded the single-tool output limit.
Persisted sessions now keep each child transcript independently, return a
bounded fair preview plus stable reference for every result, and page full
answers through the conversation-scoped `read_subagent_result` tool.
- Fixed Remote Workbench failing with only `initialize: workbench-desktop:
connection closed` on fresh or cross-platform SSH hosts. Desktop now proves
the exact Host CLI Build ID, provisions the matching verified release without
+8
View File
@@ -1178,6 +1178,14 @@ the strict read-only entrances:
| `reasonix review` (CLI) | Read-only review of a diff or branch |
| Desktop preview/review subagents | Read-only desktop analysis surfaces |
In persisted sessions, `parallel_tasks` and `fleet` return a bounded preview
plus one `Subagent reference` per completed child instead of concatenating every
full answer into a truncation-prone tool result. The parent can call
`read_subagent_result` with that reference and page by `offset_bytes`; results
are scoped to the current conversation lineage and workspace. Headless runs
without a persisted parent session remain ephemeral and receive fair bounded
previews, but cannot mint durable references.
The interactive two-model Planner uses a dedicated construction path
(`NewPlannerAgent`): it still blocks bash, file writers, and ordinary writers,
but may call authorized, non-destructive MCP through the fixed
+6
View File
@@ -920,6 +920,12 @@ destructive MCP 目标、来自未授权 server 的 reader,以及一切会改
| `reasonix review`(CLI) | 只读评审 diff 或分支 |
| 桌面端 preview/review 子代理 | 桌面端只读分析面 |
在持久化会话中,`parallel_tasks` 与 `fleet` 不再把所有完整答案拼成一个容易被截断的
工具结果,而是为每个已完成子 Agent 返回有界预览和独立的 `Subagent reference`。父 Agent
可用 `read_subagent_result` 按 `offset_bytes` 分页读取该引用对应的完整答案;读取范围受当前
会话 lineage 与工作区约束。没有持久化父会话的 headless 运行仍保持 ephemeral,只返回公平
分配的有界预览,不能生成持久引用。
交互式双模型 Planner 使用专用构造路径(`NewPlannerAgent`):仍阻止 bash、文件写入与普通
writer,但可通过固定的 `use_capability` 代理调用已授权、非 destructive 的 MCP,不再要求
`readOnlyHint`。直接 `mcp__*` schema 永不进入 Planner 工具列表,因此 MCP 安装/连接变动
+6 -1
View File
@@ -617,7 +617,12 @@ default 3). Profile names are resolved at runtime from the Skill store and
must never enter tool schemas or the parent system prompt. Custom and named
built-in profile bodies are the full child system prompt (no implicit
concise default). `parallel_tasks` remains the compatible read-only batch
API on the same scheduler. See [Subagent profiles](./SUBAGENT_PROFILES.md)
API on the same scheduler. In a persisted parent session, parallel/fleet
children save independent transcripts; the aggregate carries bounded previews
and stable refs, and `read_subagent_result` pages a referenced final answer by
UTF-8 byte offset under the current conversation-lineage/workspace boundary.
Headless runs remain ephemeral and return fair bounded previews without refs.
See [Subagent profiles](./SUBAGENT_PROFILES.md)
for the user-facing command and file-format contract.
## 4. Data Types (`internal/provider`)
+8 -1
View File
@@ -97,9 +97,16 @@ those direct tools are installed, connected, or refreshed.
`ask`, `docs`, `explore`, `fleet`, `forget`, `history`, `install_skill`, `install_source`,
`list_sessions`, `lsp_definition`, `lsp_diagnostics`, `lsp_hover`,
`lsp_references`, `memory`, `parallel_tasks`, `read_only_skill`,
`read_only_task`, `read_session`, `read_skill`, `remember`, `research`,
`read_only_task`, `read_session`, `read_skill`, `read_subagent_result`, `remember`, `research`,
`review`, `run_skill`, `security_review`, `slash_command`, `task`.
`parallel_tasks` and `fleet` keep their combined result below the single-tool
output limit by returning a fair preview and a stable `Subagent reference` for
every persisted child. `read_subagent_result` pages through one referenced
final answer by UTF-8 byte offset, so long parallel research remains lossless
without injecting every report into the parent context at once. References are
restricted to the current conversation lineage and workspace.
`use_capability` (`action` = `list` | `inspect` | `call` | `decline`): Delivery
Executor, plus both Planner and Executor in Balanced dual-model sessions; not
enabled in Economy.
+6 -1
View File
@@ -78,9 +78,14 @@ Executor 刻意保留直接 `mcp__*` 工具,因此安装、连接或刷新这
`ask`, `docs`, `explore`, `fleet`, `forget`, `history`, `install_skill`, `install_source`,
`list_sessions`, `lsp_definition`, `lsp_diagnostics`, `lsp_hover`,
`lsp_references`, `memory`, `parallel_tasks`, `read_only_skill`,
`read_only_task`, `read_session`, `read_skill`, `remember`, `research`,
`read_only_task`, `read_session`, `read_skill`, `read_subagent_result`, `remember`, `research`,
`review`, `run_skill`, `security_review`, `slash_command`, `task`.
`parallel_tasks` 与 `fleet` 会为每个已持久化子 Agent 返回公平分配的预览和稳定的
`Subagent reference`,使合并结果始终低于单工具输出上限。`read_subagent_result`
按 UTF-8 字节偏移分页读取某个引用对应的完整最终答案,因此长篇并行调研无需一次性全部
注入父会话也不会丢失。引用只允许在当前会话 lineage 和工作区内读取。
`use_capability`(`action` = `list` | `inspect` | `call` | `decline`):Delivery Executor,
以及 Balanced 双模型会话中的 Planner 和 Executor;Economy 不启用。
+28 -28
View File
@@ -36,7 +36,7 @@ func NewFleetTool(taskTool *TaskTool) *FleetTool {
func (*FleetTool) Name() string { return "fleet" }
func (*FleetTool) Description() string {
return "Dispatch 2–64 sub-agent tasks in parallel and aggregate results. Each item may select a profile, model, effort, tools, write_paths, or read_only. Multiple writers must declare non-overlapping write_paths; omitted write_paths claim the whole workspace, so two or more writers without paths fail preflight before any task starts. Independent failure is the default: one failure does not cancel others. Background mode returns a fleet job id collectable with wait."
return "Dispatch 2–64 sub-agent tasks in parallel 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. Multiple writers must declare non-overlapping write_paths; omitted write_paths claim the whole workspace, so two or more writers without paths fail preflight before any task starts. Independent failure is the default: one failure does not cancel others. Background mode returns a fleet job id collectable with wait."
}
func (*FleetTool) Schema() json.RawMessage {
@@ -244,10 +244,10 @@ func (f *FleetTool) runFleet(ctx context.Context, sink event.Sink, specs []Profi
// transcripts, evidence, and scheduler claims stay independent.
itemCtx := withCallContext(ctx, subID, subSinkFor(subID, sink), nil, false)
out, err := f.taskTool.RunProfileSpec(itemCtx, spec)
res := fleetItemResult{index: idx, profile: spec.Profile, output: out, err: err}
answer, ref := splitSubagentRunResult(out)
res := fleetItemResult{index: idx, profile: spec.Profile, output: answer, ref: ref, err: err}
if err == nil {
res.status = fleetItemCompleted
res.ref = extractSubagentRef(out)
sink.Emit(event.Event{
Kind: event.ToolResult,
Tool: event.Tool{ID: subID, ParentID: parentID, Name: "task", Output: out},
@@ -322,7 +322,7 @@ func (f *FleetTool) runFleet(ctx context.Context, sink event.Sink, specs []Profi
func formatFleetAggregate(results []fleetItemResult, cancelled bool) string {
n := len(results)
var b strings.Builder
var prefix string
if cancelled {
completed := 0
for _, r := range results {
@@ -330,41 +330,41 @@ func formatFleetAggregate(results []fleetItemResult, cancelled bool) string {
completed++
}
}
fmt.Fprintf(&b, "Cancelled fleet after completing %d of %d tasks:\n", completed, n)
prefix = fmt.Sprintf("Cancelled fleet after completing %d of %d tasks:\n", completed, n)
} else {
fmt.Fprintf(&b, "Completed fleet of %d tasks:\n", n)
prefix = fmt.Sprintf("Completed fleet of %d tasks:\n", n)
}
items := make([]subagentAggregateItem, 0, n)
for i, r := range results {
fmt.Fprintf(&b, "── task-%d", i+1)
header := fmt.Sprintf("── task-%d", i+1)
if r.profile != "" {
fmt.Fprintf(&b, " profile=%s", r.profile)
header += " profile=" + boundedInline(r.profile, 80)
}
b.WriteString(" ──\n")
header += " ──\n"
item := subagentAggregateItem{header: header, ref: r.ref}
switch r.status {
case fleetItemCompleted:
fmt.Fprintf(&b, "status: completed\n%s\n", strings.TrimSpace(r.output))
if r.ref != "" {
fmt.Fprintf(&b, "Subagent reference: %s\n", r.ref)
}
item.status = "status: completed\n"
item.answer = strings.TrimSpace(r.output)
case fleetItemFailed:
fmt.Fprintf(&b, "status: failed\n[FAILED] %v\n", r.err)
item.status = "status: failed\n"
if r.err != nil {
item.detail = fmt.Sprintf("[FAILED] %s\n", boundedInline(r.err.Error(), 256))
}
case fleetItemCancelled:
fmt.Fprintf(&b, "status: cancelled\n[CANCELLED] %v\n", r.err)
item.status = "status: cancelled\n"
if r.err != nil {
item.detail = fmt.Sprintf("[CANCELLED] %s\n", boundedInline(r.err.Error(), 256))
}
case fleetItemSkipped:
fmt.Fprintf(&b, "status: skipped\n[SKIPPED] %v\n", r.err)
item.status = "status: skipped\n"
if r.err != nil {
item.detail = fmt.Sprintf("[SKIPPED] %s\n", boundedInline(r.err.Error(), 256))
}
default:
fmt.Fprintf(&b, "status: pending\n")
item.status = "status: pending\n"
}
items = append(items, item)
}
return b.String()
}
func extractSubagentRef(output string) string {
const prefix = "Subagent reference: "
for _, line := range strings.Split(output, "\n") {
if strings.HasPrefix(line, prefix) {
return strings.TrimSpace(strings.TrimPrefix(line, prefix))
}
}
return ""
return formatBoundedSubagentAggregate(prefix, items)
}
+25
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"sync/atomic"
@@ -285,6 +286,30 @@ func TestFleetParallelDisjointWriters(t *testing.T) {
}
}
func TestFleetAggregatePreservesEveryReferenceUnderToolLimit(t *testing.T) {
results := make([]fleetItemResult, 3)
for i := range results {
results[i] = fleetItemResult{
index: i,
status: fleetItemCompleted,
output: fmt.Sprintf("BEGIN-%d\n%s\nEND-%d", i+1, strings.Repeat(string(rune('a'+i)), 20*1024), i+1),
ref: fmt.Sprintf("sa_result_%d", i+1),
}
}
out := formatFleetAggregate(results, false)
if len(out) > subagentAggregateBudgetBytes {
t.Fatalf("aggregate bytes = %d, want <= %d", len(out), subagentAggregateBudgetBytes)
}
if _, notice := truncateToolOutput(out); notice != "" {
t.Fatalf("bounded fleet aggregate still hit generic truncation: %s", notice)
}
for i := range results {
if !strings.Contains(out, results[i].ref) {
t.Fatalf("aggregate lost ref %q", results[i].ref)
}
}
}
type fleetBarrierProvider struct {
onPrompt func()
}
+55 -90
View File
@@ -9,9 +9,7 @@ import (
"strings"
"sync"
"reasonix/internal/checkpoint"
"reasonix/internal/event"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
@@ -21,19 +19,19 @@ import (
// cards for each sub-task.
type ParallelTasksTool struct {
taskTool *TaskTool
reg *tool.Registry
}
// NewParallelTasksTool creates a parallel dispatch tool that reuses the given
// TaskTool's sub-agent infrastructure.
func NewParallelTasksTool(taskTool *TaskTool, reg *tool.Registry) *ParallelTasksTool {
return &ParallelTasksTool{taskTool: taskTool, reg: reg}
_ = reg // retained for source compatibility with existing constructors
return &ParallelTasksTool{taskTool: taskTool}
}
func (p *ParallelTasksTool) Name() string { return "parallel_tasks" }
func (p *ParallelTasksTool) Description() string {
return "Dispatch multiple read-only sub-agent tasks concurrently and collect their results. Each task runs in its own read-only sub-agent in parallel. Blocks until all complete."
return "Dispatch multiple read-only sub-agent tasks concurrently. Blocks until all complete, then returns a bounded preview and a stable Subagent reference for every completed persisted child; use read_subagent_result to page through any full answer without combined-result truncation."
}
func (p *ParallelTasksTool) Schema() json.RawMessage {
@@ -124,6 +122,7 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
type subResult struct {
index int
output string
ref string
err error
}
@@ -132,6 +131,7 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
running := make([]bool, n)
done := make([]bool, n)
outputs := make([]string, n)
refs := make([]string, n)
taskErrs := make([]error, n)
statuses := make([]parallelTaskStatus, n)
for i := range params.Tasks {
@@ -164,65 +164,25 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
wg.Add(1)
go func() {
defer wg.Done()
nested := subSinkFor(subID, sink)
// Session scheduler bounds total concurrency for parallel_tasks the
// same way as fleet (read-only slots; no write claims).
releaseSlot, slotErr := p.taskTool.acquireSlot(ctx, AcquireRequest{
Writer: false,
Nested: SubagentDepth(ctx) > 0,
Label: label,
})
if slotErr != nil {
sink.Emit(event.Event{
Kind: event.ToolResult,
Tool: event.Tool{ID: subID, ParentID: parentID, Name: "task", Err: slotErr.Error()},
})
doneCh <- subResult{index: idx, err: slotErr}
return
}
defer releaseSlot()
modelRef, effortRef := p.taskTool.effectiveProfile(t.Model, t.Effort)
usageModelRef := p.taskTool.usageModelRef(modelRef, effortRef)
childDepth, depthErr := p.taskTool.nextSubagentDepth(ctx)
if depthErr != nil {
sink.Emit(event.Event{
Kind: event.ToolResult,
Tool: event.Tool{ID: subID, ParentID: parentID, Name: "task", Err: depthErr.Error()},
})
doneCh <- subResult{index: idx, err: depthErr}
return
}
subReg := ReadOnlySubagentToolRegistryForDepthWithRuntime(p.taskTool.parentReg, t.Tools, childDepth, p.taskTool.maxDepth(), p.taskTool.capabilityRuntime)
max := p.taskTool.childMaxSteps(t.MaxSteps)
prov, pricing, ctxWin, err := resolveSubagentProvider(p.taskTool, modelRef, effortRef)
if err != nil {
sink.Emit(event.Event{
Kind: event.ToolResult,
Tool: event.Tool{ID: subID, ParentID: parentID, Name: "task", Err: err.Error()},
})
doneCh <- subResult{index: idx, err: err}
return
}
// Ordinary parallel_tasks (no profile) keep the concise read-only
// default system prompt — profile-aware batches use fleet instead.
sess := NewSession(DefaultReadOnlyTaskSystemPrompt)
recoveryTaskID := "subagent:" + subID
var mutationObserver *checkpoint.MutationObserver
if p.taskTool.mutationObserver != nil {
mutationObserver = p.taskTool.mutationObserver.CloneForSubagent(recoveryTaskID, p.taskTool.mutationObserver.OwnershipTurn(), false)
}
opts := p.taskTool.subagentOptions(ctx, max, pricing, ctxWin, childDepth, recoveryTaskID, mutationObserver)
opts.ModelRef = usageModelRef
// Same contract as runSubSession: capture the pristine task before
// host framing is prepended so delivery intent classification judges
// the task, not the wrapper.
opts.ClassifierTaskText = t.Prompt
output, runErr := RunReadOnlySubAgentWithSession(ctx, prov, subReg, sess, p.taskTool.withWorkspaceContext(t.Prompt),
opts, nested)
itemCtx := withCallContext(ctx, subID, subSinkFor(subID, sink), nil, PlanModeFromContext(ctx))
// Route through TaskTool's unified runner so persisted parent sessions
// retain one independently readable transcript per child. Headless runs
// remain ephemeral and still receive fair bounded previews.
output, runErr := p.taskTool.RunProfileSpec(itemCtx, ProfileExecSpec{
Kind: "task",
Name: "task",
Prompt: t.Prompt,
Description: label,
CallTools: t.Tools,
MaxSteps: t.MaxSteps,
Model: modelRef,
Effort: effortRef,
ReadOnly: true,
AllowNoTools: true,
Nested: SubagentDepth(ctx) > 0,
SystemPrompt: DefaultReadOnlyTaskSystemPrompt,
})
if ctx.Err() != nil && runErr == nil {
runErr = ctx.Err()
@@ -243,7 +203,8 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
Kind: event.ToolResult,
Tool: event.Tool{ID: subID, ParentID: parentID, Name: "task", Output: output},
})
doneCh <- subResult{index: idx, output: output}
answer, ref := splitSubagentRunResult(output)
doneCh <- subResult{index: idx, output: answer, ref: ref}
}()
}
@@ -274,6 +235,7 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
completed++
done[r.index] = true
outputs[r.index] = r.output
refs[r.index] = r.ref
taskErrs[r.index] = r.err
switch {
case r.err == nil:
@@ -301,7 +263,7 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
}
markCancelled(err)
wg.Wait()
return formatParallelTasksAggregate(outputs, taskErrs, statuses, true), err
return formatParallelTasksAggregate(outputs, refs, taskErrs, statuses, true), err
}
}
wg.Wait()
@@ -310,9 +272,9 @@ func (p *ParallelTasksTool) Execute(ctx context.Context, args json.RawMessage) (
if err == nil {
err = context.Canceled
}
return formatParallelTasksAggregate(outputs, taskErrs, statuses, true), err
return formatParallelTasksAggregate(outputs, refs, taskErrs, statuses, true), err
}
return formatParallelTasksAggregate(outputs, taskErrs, statuses, false), nil
return formatParallelTasksAggregate(outputs, refs, taskErrs, statuses, false), nil
}
func parallelTasksWereCancelled(statuses []parallelTaskStatus) bool {
@@ -324,9 +286,9 @@ func parallelTasksWereCancelled(statuses []parallelTaskStatus) bool {
return false
}
func formatParallelTasksAggregate(outputs []string, errs []error, statuses []parallelTaskStatus, cancelled bool) string {
func formatParallelTasksAggregate(outputs, refs []string, errs []error, statuses []parallelTaskStatus, cancelled bool) string {
n := len(statuses)
var b strings.Builder
var prefix string
if cancelled {
completed := 0
for _, st := range statuses {
@@ -334,34 +296,47 @@ func formatParallelTasksAggregate(outputs []string, errs []error, statuses []par
completed++
}
}
fmt.Fprintf(&b, "Cancelled parallel tasks after completing %d of %d tasks:\n", completed, n)
prefix = fmt.Sprintf("Cancelled parallel tasks after completing %d of %d tasks:\n", completed, n)
} else {
fmt.Fprintf(&b, "Completed %d parallel tasks:\n", n)
prefix = fmt.Sprintf("Completed %d parallel tasks:\n", n)
}
items := make([]subagentAggregateItem, 0, n)
for i, st := range statuses {
fmt.Fprintf(&b, "── task-%d ──\n", i+1)
item := subagentAggregateItem{header: fmt.Sprintf("── task-%d ──\n", i+1)}
switch st {
case parallelTaskCompleted:
fmt.Fprintf(&b, "%s\n", strings.TrimSpace(outputs[i]))
item.status = "status: completed\n"
item.answer = strings.TrimSpace(outputs[i])
if i < len(refs) {
item.ref = refs[i]
}
case parallelTaskCancelled:
item.status = "status: cancelled\n"
if errs[i] != nil {
fmt.Fprintf(&b, "[CANCELLED] %s\n", errs[i])
item.detail = fmt.Sprintf("[CANCELLED] %s\n", boundedInline(errs[i].Error(), 256))
} else {
b.WriteString("[CANCELLED]\n")
item.detail = "[CANCELLED]\n"
}
case parallelTaskSkipped:
item.status = "status: skipped\n"
if errs[i] != nil {
fmt.Fprintf(&b, "[SKIPPED] cancelled before start: %s\n", errs[i])
item.detail = fmt.Sprintf("[SKIPPED] cancelled before start: %s\n", boundedInline(errs[i].Error(), 256))
} else {
b.WriteString("[SKIPPED] cancelled before start\n")
item.detail = "[SKIPPED] cancelled before start\n"
}
case parallelTaskFailed:
fmt.Fprintf(&b, "[FAILED] %s\n", errs[i])
item.status = "status: failed\n"
if errs[i] != nil {
item.detail = fmt.Sprintf("[FAILED] %s\n", boundedInline(errs[i].Error(), 256))
} else {
item.detail = "[FAILED]\n"
}
default:
b.WriteString("[PENDING]\n")
item.status = "status: pending\n"
}
items = append(items, item)
}
return b.String()
return formatBoundedSubagentAggregate(prefix, items)
}
func validateParallelTaskItems(tasks []parallelTaskItem) error {
@@ -372,13 +347,3 @@ func validateParallelTaskItems(tasks []parallelTaskItem) error {
}
return nil
}
// resolveSubagentProvider resolves a provider for a sub-agent, using the
// TaskTool's resolver or falling back to the task tool's own provider.
func resolveSubagentProvider(tt *TaskTool, modelRef, effortRef string) (provider.Provider, *provider.Pricing, int, error) {
if tt.resolveProvider != nil && (modelRef != "" || effortRef != "") {
return tt.resolveProvider(modelRef, effortRef)
}
// Use the task tool's own defaults.
return tt.prov, tt.pricing, tt.contextWindow, nil
}
+77
View File
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"strconv"
"strings"
"sync/atomic"
@@ -127,6 +128,56 @@ func TestParallelTasksForegroundCompletesAndClosesWorkers(t *testing.T) {
}
}
func TestParallelTasksLongResultsStayIndependentlyRetrievable(t *testing.T) {
workspace := t.TempDir()
store := NewSubagentStore(t.TempDir())
task := NewTaskTool(parallelLongResultProvider{}, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
WithTranscripts(store, workspace, "base-model", "base-effort")
parallel := NewParallelTasksTool(task, tool.NewRegistry())
ctx := WithParentSession(withCallContext(context.Background(), "parallel-call", event.Discard, nil, false), "parent-session")
out, err := parallel.Execute(ctx, json.RawMessage(`{"tasks":[{"prompt":"first report"},{"prompt":"second report"}]}`))
if err != nil {
t.Fatalf("Execute: %v", err)
}
if len(out) > subagentAggregateBudgetBytes {
t.Fatalf("aggregate bytes = %d, want <= %d", len(out), subagentAggregateBudgetBytes)
}
if _, notice := truncateToolOutput(out); notice != "" {
t.Fatalf("bounded aggregate still hit the generic truncator: %s", notice)
}
if strings.Count(out, "Subagent reference: sa_") != 2 {
t.Fatalf("aggregate did not preserve every child ref:\n%s", out)
}
if !strings.Contains(out, "preview truncated; read the full result") {
t.Fatalf("aggregate did not explain lossless retrieval:\n%s", out)
}
refs := subagentRefsFromText(out)
if len(refs) != 2 {
t.Fatalf("refs = %v, want 2", refs)
}
reader := NewSubagentResultTool(task)
for i, ref := range refs {
page, readErr := reader.Execute(ctx, json.RawMessage(fmt.Sprintf(`{"ref":%q,"limit_bytes":24576}`, ref)))
if readErr != nil {
t.Fatalf("read result %d: %v", i+1, readErr)
}
wantBegin, wantEnd := "FIRST-BEGIN", "FIRST-END"
if i == 1 {
wantBegin, wantEnd = "SECOND-BEGIN", "SECOND-END"
}
if !strings.Contains(page, wantBegin) || !strings.Contains(page, wantEnd) || !strings.Contains(page, "End of subagent result") {
t.Fatalf("read result %d was not complete: head=%q tail=%q", i+1, page[:minInt(120, len(page))], page[max(0, len(page)-120):])
}
}
otherCtx := WithParentSession(context.Background(), "other-session")
if _, err := reader.Execute(otherCtx, json.RawMessage(fmt.Sprintf(`{"ref":%q}`, refs[0]))); err == nil {
t.Fatal("reader accepted a result from an unrelated parent session")
}
}
func TestParallelTasksInjectsWorkspaceContextIntoChildren(t *testing.T) {
workspace := t.TempDir()
task := NewTaskTool(promptRoutingProvider{}, nil, tool.NewRegistry(), 20, 0, 0, 0, 0, 0, 0, 0.0, "", "sys", nil, 0, "", "", nil).
@@ -308,6 +359,32 @@ func (parallelStaticProvider) Stream(context.Context, provider.Request) (<-chan
return ch, nil
}
type parallelLongResultProvider struct{}
func (parallelLongResultProvider) Name() string { return "parallel-long-result" }
func (parallelLongResultProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
begin, end, fill := "FIRST-BEGIN", "FIRST-END", "a"
if strings.Contains(lastUser(req), "second report") {
begin, end, fill = "SECOND-BEGIN", "SECOND-END", "b"
}
ch := make(chan provider.Chunk, 2)
ch <- provider.Chunk{Type: provider.ChunkText, Text: begin + "\n" + strings.Repeat(fill, 20*1024) + "\n" + end}
ch <- provider.Chunk{Type: provider.ChunkDone}
close(ch)
return ch, nil
}
func subagentRefsFromText(text string) []string {
var refs []string
for _, line := range strings.Split(text, "\n") {
if strings.HasPrefix(line, "Subagent reference: ") {
refs = append(refs, strings.TrimSpace(strings.TrimPrefix(line, "Subagent reference: ")))
}
}
return refs
}
type promptRoutingProvider struct{}
func (promptRoutingProvider) Name() string { return "prompt-routing" }
+4
View File
@@ -53,6 +53,10 @@ type ProfileExecSpec struct {
UseProfilePrompt bool
// ReadOnly forces the read-only registry even when the profile can write.
ReadOnly bool
// AllowNoTools preserves the ordinary parallel research path for children
// that can answer directly without host tools. Explicit task/profile calls
// keep failing closed on an empty registry.
AllowNoTools bool
// CallTools is the optional per-call tools whitelist.
CallTools []string
// ProfileTools is the profile frontmatter allowed-tools list.
+303
View File
@@ -0,0 +1,303 @@
package agent
import (
"bytes"
"context"
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
"reasonix/internal/provider"
)
const (
// Leave room for the generic tool-result guard to add metadata without
// clipping a task manifest. The aggregate itself owns fair per-task preview
// allocation so every completed child keeps its status and retrieval ref.
subagentAggregateBudgetBytes = maxToolOutputBytes - 512
subagentResultDefaultBytes = 12 * 1024
subagentResultMaxBytes = 24 * 1024
)
// SubagentResultTool pages through the final answer of a completed persisted
// sub-agent. Parallel/fleet aggregates use this stable reader instead of
// forcing every child answer through one fixed-size tool result.
type SubagentResultTool struct {
store *SubagentStore
workspaceRoot string
}
func NewSubagentResultTool(task *TaskTool) *SubagentResultTool {
if task == nil {
return &SubagentResultTool{}
}
return &SubagentResultTool{store: task.transcripts, workspaceRoot: task.workspaceRoot}
}
func (*SubagentResultTool) Name() string { return "read_subagent_result" }
func (*SubagentResultTool) Description() string {
return "Read a completed sub-agent's full final answer by the Subagent reference returned from task, parallel_tasks, or fleet. Results are scoped to the current conversation lineage and paged by UTF-8 byte offset so large answers remain lossless without overflowing one tool result."
}
func (*SubagentResultTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"ref":{"type":"string","description":"The sa_... value from a Subagent reference line."},"offset_bytes":{"type":"integer","description":"UTF-8 byte offset to start reading from. Omit for the beginning; use next_offset_bytes from the previous page.","minimum":0},"limit_bytes":{"type":"integer","description":"Maximum UTF-8 bytes to return. Defaults to 12288 and is capped at 24576.","minimum":1,"maximum":24576}},"required":["ref"]}`)
}
func (*SubagentResultTool) ReadOnly() bool { return true }
func (*SubagentResultTool) PlanModeSafe() bool { return true }
func (t *SubagentResultTool) Execute(ctx context.Context, args json.RawMessage) (string, error) {
var p struct {
Ref string `json:"ref"`
OffsetBytes int `json:"offset_bytes"`
LimitBytes int `json:"limit_bytes"`
}
dec := json.NewDecoder(bytes.NewReader(args))
dec.DisallowUnknownFields()
if err := dec.Decode(&p); err != nil {
return "", fmt.Errorf("invalid args: %w", err)
}
p.Ref = strings.TrimSpace(p.Ref)
if p.Ref == "" {
return "", fmt.Errorf("ref is required")
}
if p.OffsetBytes < 0 {
return "", fmt.Errorf("offset_bytes must be non-negative")
}
if p.LimitBytes == 0 {
p.LimitBytes = subagentResultDefaultBytes
}
if p.LimitBytes < 1 || p.LimitBytes > subagentResultMaxBytes {
return "", fmt.Errorf("limit_bytes must be between 1 and %d", subagentResultMaxBytes)
}
if t == nil || t.store == nil {
return "", fmt.Errorf("subagent result storage is not available in this session")
}
parentSession := ParentSession(ctx)
if parentSession == "" {
return "", fmt.Errorf("subagent result retrieval requires a persisted parent session")
}
answer, status, err := t.store.ReadFinalAnswer(p.Ref, parentSession, t.workspaceRoot)
if err != nil {
return "", err
}
if p.OffsetBytes > len(answer) {
return "", fmt.Errorf("offset_bytes %d exceeds result size %d", p.OffsetBytes, len(answer))
}
if p.OffsetBytes < len(answer) && !utf8.RuneStart(answer[p.OffsetBytes]) {
return "", fmt.Errorf("offset_bytes %d is not at a UTF-8 character boundary; use next_offset_bytes from the previous page", p.OffsetBytes)
}
end := p.OffsetBytes + p.LimitBytes
if end > len(answer) {
end = len(answer)
}
for end > p.OffsetBytes && end < len(answer) && !utf8.RuneStart(answer[end]) {
end--
}
var b strings.Builder
fmt.Fprintf(&b, "Subagent result %s (status=%s, bytes %d-%d of %d):\n", p.Ref, status, p.OffsetBytes, end, len(answer))
b.WriteString(answer[p.OffsetBytes:end])
if end < len(answer) {
fmt.Fprintf(&b, "\n\nMore remains. Call read_subagent_result with ref=%q and offset_bytes=%d.", p.Ref, end)
} else {
b.WriteString("\n\nEnd of subagent result.")
}
return b.String(), nil
}
// ReadFinalAnswer returns a completed child answer only when the caller owns
// the parent conversation (or a verified descendant) and the workspace still
// matches. The per-ref lock prevents a read racing the terminal transcript save.
func (s *SubagentStore) ReadFinalAnswer(ref, parentSession, workspaceRoot string) (string, SubagentStatus, error) {
if s == nil {
return "", "", fmt.Errorf("subagent result storage is not available")
}
ref = strings.TrimSpace(ref)
parentSession = strings.TrimSpace(parentSession)
if parentSession == "" {
return "", "", fmt.Errorf("subagent result parent session is required")
}
release, err := s.lock(ref)
if err != nil {
return "", "", err
}
defer release()
meta, err := s.LoadMeta(ref)
if err != nil {
return "", "", err
}
owner := strings.TrimSpace(meta.ParentSession)
if owner != parentSession {
ok, lineageErr := s.isAncestorSession(owner, parentSession)
if lineageErr != nil {
return "", meta.Status, fmt.Errorf("subagent reference %q ownership could not be verified: %w", ref, lineageErr)
}
if !ok {
return "", meta.Status, fmt.Errorf("subagent reference %q does not belong to the current conversation lineage", ref)
}
}
if want := strings.TrimSpace(workspaceRoot); want != "" && strings.TrimSpace(meta.WorkspaceRoot) != want {
return "", meta.Status, fmt.Errorf("subagent reference %q belongs to a different workspace", ref)
}
if meta.Status != SubagentCompleted {
return "", meta.Status, fmt.Errorf("subagent reference %q is %s; only completed results can be read", ref, meta.Status)
}
sess, err := LoadSession(s.sessionPath(ref))
if err != nil {
return "", meta.Status, fmt.Errorf("load subagent transcript %q: %w", ref, err)
}
msgs := sess.Snapshot()
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == provider.RoleAssistant && strings.TrimSpace(msgs[i].Content) != "" {
return msgs[i].Content, meta.Status, nil
}
}
return "", meta.Status, fmt.Errorf("subagent reference %q has no final assistant answer", ref)
}
type subagentAggregateItem struct {
header string
status string
answer string
ref string
detail string
}
func formatBoundedSubagentAggregate(prefix string, items []subagentAggregateItem) string {
baseBytes := len(prefix)
completed := 0
for _, item := range items {
baseBytes += len(item.header) + len(item.status) + len(item.detail)
if item.ref != "" {
baseBytes += len("Subagent reference: \n") + len(item.ref)
}
if item.answer != "" {
baseBytes += len("Final answer preview:\n\n")
completed++
}
}
available := subagentAggregateBudgetBytes - baseBytes
if available < 0 {
available = 0
}
perAnswer := 0
if completed > 0 {
perAnswer = available / completed
}
var b strings.Builder
b.Grow(minInt(subagentAggregateBudgetBytes, baseBytes+available))
b.WriteString(prefix)
for _, item := range items {
b.WriteString(item.header)
b.WriteString(item.status)
if item.ref != "" {
fmt.Fprintf(&b, "Subagent reference: %s\n", item.ref)
}
if item.detail != "" {
b.WriteString(item.detail)
}
if item.answer != "" {
b.WriteString("Final answer preview:\n")
b.WriteString(subagentAnswerPreview(item.answer, item.ref, perAnswer))
b.WriteByte('\n')
}
}
return b.String()
}
func subagentAnswerPreview(answer, ref string, limit int) string {
answer = strings.TrimSpace(answer)
if len(answer) <= limit {
return answer
}
if limit <= 0 {
return ""
}
marker := "\n…[preview truncated; full result unavailable in this ephemeral run]…\n"
if ref != "" {
marker = fmt.Sprintf("\n…[preview truncated; read the full result with read_subagent_result(ref=%q)]…\n", ref)
}
if len(marker) >= limit {
return utf8Prefix(answer, limit)
}
keep := limit - len(marker)
headBytes := keep / 2
tailBytes := keep - headBytes
head := utf8Prefix(answer, headBytes)
tail := utf8Suffix(answer, tailBytes)
return head + marker + tail
}
func utf8Prefix(s string, limit int) string {
if limit >= len(s) {
return s
}
if limit <= 0 {
return ""
}
for limit > 0 && !utf8.RuneStart(s[limit]) {
limit--
}
return s[:limit]
}
func utf8Suffix(s string, limit int) string {
if limit >= len(s) {
return s
}
if limit <= 0 {
return ""
}
start := len(s) - limit
for start < len(s) && !utf8.RuneStart(s[start]) {
start++
}
return s[start:]
}
func boundedInline(s string, limit int) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) <= limit {
return s
}
if limit <= len("…") {
return utf8Prefix(s, limit)
}
return utf8Prefix(s, limit-len("…")) + "…"
}
func splitSubagentRunResult(output string) (answer, ref string) {
ref = extractSubagentRef(output)
if ref == "" {
return strings.TrimSpace(output), ""
}
const marker = "\n\nFinal answer:\n"
if idx := strings.Index(output, marker); idx >= 0 {
return strings.TrimSpace(output[idx+len(marker):]), ref
}
return strings.TrimSpace(output), ref
}
func extractSubagentRef(output string) string {
const prefix = "Subagent reference: "
if !strings.HasPrefix(output, prefix) {
return ""
}
line := output
if end := strings.IndexByte(line, '\n'); end >= 0 {
line = line[:end]
}
ref := strings.TrimSpace(strings.TrimPrefix(line, prefix))
if validSubagentRef(ref) {
return ref
}
return ""
}
+81
View File
@@ -0,0 +1,81 @@
package agent
import (
"context"
"encoding/json"
"strings"
"testing"
"unicode/utf8"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
func TestSubagentResultToolContractIsStableAndReadOnly(t *testing.T) {
reader := NewSubagentResultTool(nil)
if reader.Name() != "read_subagent_result" || !reader.ReadOnly() || !reader.PlanModeSafe() {
t.Fatalf("reader contract = name %q readOnly=%v planSafe=%v", reader.Name(), reader.ReadOnly(), reader.PlanModeSafe())
}
if !json.Valid(reader.Schema()) {
t.Fatalf("reader schema is invalid: %s", reader.Schema())
}
canonical := provider.CanonicalizeSchema(reader.Schema())
if got := string(provider.CanonicalizeSchema(canonical)); got != string(canonical) {
t.Fatalf("reader schema canonicalization is not stable:\n%s", canonical)
}
}
func TestSplitSubagentRunResultDoesNotRewriteEphemeralAnswer(t *testing.T) {
ephemeral := "Research notes\n\nFinal answer:\nkeep this heading and everything before it"
if answer, ref := splitSubagentRunResult(ephemeral); answer != ephemeral || ref != "" {
t.Fatalf("ephemeral answer was treated as a persisted envelope: answer=%q ref=%q", answer, ref)
}
persisted := "Subagent reference: sa_test\n\nFinal answer:\nanswer only"
if answer, ref := splitSubagentRunResult(persisted); answer != "answer only" || ref != "sa_test" {
t.Fatalf("persisted envelope was not split: answer=%q ref=%q", answer, ref)
}
}
func TestSubagentResultToolPagesOnUTF8Boundaries(t *testing.T) {
workspace := t.TempDir()
store := NewSubagentStore(t.TempDir())
run, err := store.PrepareFresh(SubagentSpec{
Kind: "task",
Name: "task",
WorkspaceRoot: workspace,
ParentSession: "parent-session",
SystemPrompt: "sys",
Registry: tool.NewRegistry(),
})
if err != nil {
t.Fatalf("PrepareFresh: %v", err)
}
answer := "BEGIN\n" + strings.Repeat("中", 10_000) + "\nEND"
run.Session.Add(provider.Message{Role: provider.RoleUser, Content: "research"})
run.Session.Add(provider.Message{Role: provider.RoleAssistant, Content: answer})
if err := store.SaveCompleted(run); err != nil {
run.Release()
t.Fatalf("SaveCompleted: %v", err)
}
run.Release()
reader := &SubagentResultTool{store: store, workspaceRoot: workspace}
ctx := WithParentSession(context.Background(), "parent-session")
first, err := reader.Execute(ctx, json.RawMessage(`{"ref":"`+run.Ref+`","limit_bytes":10001}`))
if err != nil {
t.Fatalf("first page: %v", err)
}
if !utf8.ValidString(first) || !strings.Contains(first, "offset_bytes=9999") || !strings.Contains(first, "More remains") {
t.Fatalf("first page did not end on the expected UTF-8 boundary:\n%s", first[max(0, len(first)-240):])
}
second, err := reader.Execute(ctx, json.RawMessage(`{"ref":"`+run.Ref+`","offset_bytes":9999,"limit_bytes":24576}`))
if err != nil {
t.Fatalf("second page: %v", err)
}
if !utf8.ValidString(second) || !strings.Contains(second, "END") || !strings.Contains(second, "End of subagent result") {
t.Fatalf("second page did not finish the answer: %s", second[max(0, len(second)-240):])
}
if _, err := reader.Execute(ctx, json.RawMessage(`{"ref":"`+run.Ref+`","offset_bytes":10000}`)); err == nil || !strings.Contains(err.Error(), "UTF-8 character boundary") {
t.Fatalf("reader accepted a split UTF-8 offset: %v", err)
}
}
+2 -1
View File
@@ -66,6 +66,7 @@ var subagentRecursiveTools = []string{
var subagentAlwaysHiddenTools = []string{
"parallel_tasks",
"fleet",
"read_subagent_result",
"install_skill",
"install_source",
}
@@ -800,7 +801,7 @@ func (t *TaskTool) RunProfileSpec(ctx context.Context, spec ProfileExecSpec) (st
var subReg *tool.Registry
if spec.ReadOnly {
subReg = ReadOnlySubagentToolRegistryForDepthWithRuntime(t.parentReg, toolNames, childDepth, t.maxDepth(), t.capabilityRuntime)
if subReg.Len() == 0 {
if subReg.Len() == 0 && !spec.AllowNoTools {
return "", fmt.Errorf("no read-only tools available for this sub-agent")
}
} else {
+4 -2
View File
@@ -909,11 +909,13 @@ func Build(ctx context.Context, opts Options) (*control.Controller, error) {
if taskTool == nil {
taskTool = newTaskTool()
}
// Fixed registration order for prompt-cache stability: task →
// parallel_tasks → fleet. Profile names never enter tool schemas.
// The registry exports schemas in stable name order. Keep this surface
// static: profile names and result refs never enter provider-visible
// schemas, and the result reader does not change between turns.
reg.Add(taskTool)
reg.Add(agent.NewParallelTasksTool(taskTool, reg))
reg.Add(agent.NewFleetTool(taskTool))
reg.Add(agent.NewSubagentResultTool(taskTool))
return "enabled task."
}
addReadOnlyTaskTool := func() string {
+1
View File
@@ -2213,6 +2213,7 @@ func defaultFullBootToolNames() []string {
"read_only_task",
"read_session",
"read_skill",
"read_subagent_result",
"remember",
"research",
"review",