mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
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:
1 parent
8f6015118e
commit
2c54501b5e
16 files changed
+619
-124
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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`)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -2213,6 +2213,7 @@ func defaultFullBootToolNames() []string {
|
||||
"read_only_task",
|
||||
"read_session",
|
||||
"read_skill",
|
||||
"read_subagent_result",
|
||||
"remember",
|
||||
"research",
|
||||
"review",
|
||||
|
||||
Reference in new issue
Block a user