mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #14401 from SivanCola/codex/isolation-runtime-options-14025
fix(isolation): honor task runtime options / 隔离任务遵守模型与步数参数
This commit is contained in:
11 files changed
+411
-29
No files matched your search
@@ -0,0 +1,115 @@
|
||||
---
|
||||
owner: @SivanCola
|
||||
backup: @esengine
|
||||
status: active
|
||||
reviewed: 2026-10-25
|
||||
---
|
||||
|
||||
# Isolated runtime options / 隔离任务运行参数
|
||||
|
||||
## Cause and scope / 根因与范围
|
||||
|
||||
The task tool dispatched `isolation: "worktree"` before ordinary task-spec
|
||||
resolution. That branch forwarded the prompt and explicit model, but silently
|
||||
dropped `effort`, `max_steps` and the configured subagent model.
|
||||
|
||||
Its separate
|
||||
kernel could therefore run with a different model/depth and without the requested
|
||||
step cap. This is parameter loss, not evidence of a permission bypass.
|
||||
|
||||
An adjacent shared model-resolution defect reloaded a parent-model alias from
|
||||
configuration instead of preserving the actual parent entry, losing a session
|
||||
effort override. Canonical references already preserved that entry.
|
||||
|
||||
任务工具在普通任务规格解析前分流到 worktree,只转发提示与显式模型,遗漏 effort、
|
||||
max_steps 和已配置的子代理模型。隔离子内核因此可能使用错误模型、思考深度或步数上限。
|
||||
这属于参数丢失,不能据此声称权限绕过。相邻的模型别名解析还会重新读取配置,丢失父
|
||||
会话已生效的 effort 覆盖;规范模型引用原本会保留该覆盖。
|
||||
|
||||
## Source, owner and sink / 来源、所有者与执行端
|
||||
|
||||
- Task arguments and existing task defaults enter the same model/effort and
|
||||
child-budget resolution owners used by ordinary tasks. Vision routing remains
|
||||
in the existing child-runtime resolver.
|
||||
- The isolated runner receives ephemeral `Run.Effort` and `Run.MaxSteps` values.
|
||||
Boot resolves a `provider.Selection` through the shared subagent model/effort
|
||||
validation owner, then supplies it and the cap to the unattended child's Build.
|
||||
No extra provider is constructed merely to validate a selection.
|
||||
- Parent aliases resolving to the same canonical model reuse the runtime parent
|
||||
entry. A different model keeps its own defaults; explicit effort still wins.
|
||||
- Unsupported explicit model/effort combinations remain errors. Existing
|
||||
inherited-effort policy may drop an incompatible inherited setting on a
|
||||
call-selected model, while a configured model/effort pair remains strict.
|
||||
A custom provider resolver retains its existing selection authority.
|
||||
- Best-of uses the same private unattended builder. It continues to pass its
|
||||
model with no effort override or explicit cap, preserving its prior policy.
|
||||
|
||||
显式参数和默认值通过普通任务已有的解析所有者,执行端将结果传入子内核 Build。
|
||||
新增字段只在内存中的 runner 调用传递,不增加额外 provider 构造。
|
||||
|
||||
父模型别名保留
|
||||
实际会话值,其他模型使用自身默认值;显式 effort 优先。不兼容显式参数继续报错,
|
||||
继承值沿用既有逐模型策略,自定义 resolver 的权限不变。共享 unattended builder
|
||||
的 best-of 调用继续使用原先无显式 effort/上限的策略。
|
||||
|
||||
## Authority and compatibility / 权限与兼容
|
||||
|
||||
This does not enable isolation in a session that did not offer it. Profiles,
|
||||
tool restrictions, write paths, background execution and continuation/fork
|
||||
arguments remain explicitly refused by this isolation path before execution.
|
||||
|
||||
They are not silently removed or treated as implemented. Existing approvals,
|
||||
workspace selection, sandbox policy and apply/discard behavior are unchanged.
|
||||
|
||||
Only the in-process runner and private builder signatures change. Persisted
|
||||
result records, config files, wire messages, public tool schemas and tool order
|
||||
do not change. The Store Execute signature remains compatible.
|
||||
|
||||
Zero MaxSteps
|
||||
retains the existing automatic behavior; a positive explicit cap wins, otherwise
|
||||
the ordinary child-budget rule derives it from the parent cap. This is a local
|
||||
step cap, not a shared root resource or concurrency budget.
|
||||
|
||||
不会为原本关闭的会话启用隔离。profile、工具限制、write_paths、后台和续跑/fork
|
||||
仍在执行前明确拒绝,不能视为已支持或静默删除。原审批、沙箱、应用/丢弃行为不变。
|
||||
|
||||
只改进程内调用,不改持久格式、配置、wire、工具定义及顺序。零上限沿用自动行为,
|
||||
显式正值优先,否则复用普通子任务预算规则;这不是根任务共享预算或并发限额。
|
||||
|
||||
## Regression boundary / 回归边界
|
||||
|
||||
Real `boot.Build` regressions observe actual provider construction and requests
|
||||
through both direct task and deferred capability dispatch.
|
||||
|
||||
They cover explicit
|
||||
and configured models, supported/incompatible effort, parent runtime overrides,
|
||||
parent aliases, explicit/derived/automatic step caps and unchanged system/tool
|
||||
prefixes over the child run. Finite scripted providers bound the negative
|
||||
control even when the old implementation ignores its cap.
|
||||
|
||||
The predecessor owners can be compiled with the unchanged public-assembly
|
||||
behavior regression. The additional private-helper unit is excluded from that
|
||||
overlay because its helper did not exist before this repair; compilation errors
|
||||
are not counted as behavior failures.
|
||||
|
||||
真实装配测试观察 provider 构造和请求,覆盖直接与动态 capability 入口、模型与 effort
|
||||
及三类步数策略,并比较子任务各回合的系统/工具前缀。反向测试保留真实装配行为测试,
|
||||
只排除旧版本不存在的私有 helper 单测;编译错误不能算行为反例。
|
||||
|
||||
## Remaining work / 后续边界
|
||||
|
||||
Automatic isolation, durable Begin/running identity, background/continuation,
|
||||
profile/tool/write-path support, nested/root budgets, environment preparation,
|
||||
cross-process settlement, recovery journals, snapshot-bound verification and
|
||||
automatic integration remain independent series work.
|
||||
|
||||
Worktrees do not isolate
|
||||
network services, databases, credentials, ports or all shared Git metadata.
|
||||
|
||||
This author assessment is not independent security approval or native-platform
|
||||
qualification, and does not resolve historical findings from the original large
|
||||
PR. Native and hosted evidence must name the tested revision separately.
|
||||
|
||||
自动隔离、持久运行身份、后台/续跑、profile/工具/路径支持、根预算、环境准备、
|
||||
整合日志和快照验证仍独立推进。worktree 不隔离网络、数据库、凭证、端口及全部
|
||||
Git 共享元数据。作者分析不能替代独立安全审查或原生平台验证。
|
||||
@@ -73,7 +73,7 @@ func (b *builder) addBestOf() {
|
||||
func candidateRunner(parent Options, sink event.Sink, approval *candidateApproval) bestof.Runner {
|
||||
return func(ctx context.Context, run bestof.Run) (bestof.Outcome, error) {
|
||||
cs := &candidateSink{parent: sink}
|
||||
answer, unverified, err := runUnattended(ctx, parent, cs, approval.mode(), run.WorkspaceRoot, run.Prompt, run.Model)
|
||||
answer, unverified, err := runUnattended(ctx, parent, cs, approval.mode(), run.WorkspaceRoot, run.Prompt, provider.Selection{Ref: run.Model}, 0)
|
||||
return bestof.Outcome{Answer: answer, Unverified: unverified, Host: cs.summary()}, err
|
||||
}
|
||||
}
|
||||
@@ -82,7 +82,7 @@ func candidateRunner(parent Options, sink event.Sink, approval *candidateApprova
|
||||
// its end. Its transcript lives in a temp dir removed with it; only what sink
|
||||
// forwards reaches the parent session. A run that stopped without proving its
|
||||
// work is not an error: its readiness reason comes back as unverified.
|
||||
func runUnattended(ctx context.Context, parent Options, sink event.Sink, mode, root, prompt, model string) (answer, unverified string, err error) {
|
||||
func runUnattended(ctx context.Context, parent Options, sink event.Sink, mode, root, prompt string, selection provider.Selection, maxSteps int) (answer, unverified string, err error) {
|
||||
sessDir, err := os.MkdirTemp("", "reasonix-candidate-")
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
@@ -92,7 +92,9 @@ func runUnattended(ctx context.Context, parent Options, sink event.Sink, mode, r
|
||||
ComputerDriver: parent.ComputerDriver,
|
||||
WorkspaceRoot: root,
|
||||
Home: parent.Home,
|
||||
Model: model,
|
||||
Model: selection.Ref,
|
||||
EffortOverride: selection.Effort,
|
||||
MaxSteps: maxSteps,
|
||||
AgentPreset: parent.AgentPreset,
|
||||
ProviderResolver: parent.ProviderResolver,
|
||||
Sink: sink,
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestComputerDriverReachesStudioUnattendedChildSchema(t *testing.T) {
|
||||
approveWorkspace(t, root)
|
||||
driver := computer.NewDriver("/unused", nil)
|
||||
defer driver.Close()
|
||||
_, _, err := runUnattended(t.Context(), Options{ComputerDriver: driver}, event.Discard, "deny", root, "answer briefly", "")
|
||||
_, _, err := runUnattended(t.Context(), Options{ComputerDriver: driver}, event.Discard, "deny", root, "answer briefly", provider.Selection{}, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/base/testenv"
|
||||
"reasonix/internal/contract/event"
|
||||
"reasonix/internal/contract/provider"
|
||||
"reasonix/internal/session/control"
|
||||
)
|
||||
|
||||
const isolationRuntimePrompt = "ISOLATED-RUNTIME: inspect a.txt"
|
||||
|
||||
type isolationRuntimeRequest struct {
|
||||
model, effort string
|
||||
request provider.Request
|
||||
}
|
||||
|
||||
type isolationRuntimeRecorder struct {
|
||||
mu sync.Mutex
|
||||
requests []isolationRuntimeRequest
|
||||
args map[string]any
|
||||
deferred bool
|
||||
result string
|
||||
}
|
||||
|
||||
type isolationRuntimeProvider struct {
|
||||
recorder *isolationRuntimeRecorder
|
||||
model, effort string
|
||||
}
|
||||
|
||||
func (*isolationRuntimeProvider) Name() string { return "boot-isolation-runtime" }
|
||||
|
||||
func (p *isolationRuntimeProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
|
||||
r := p.recorder
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
ch := make(chan provider.Chunk, 2)
|
||||
call := func(id, name string, args any) {
|
||||
raw, _ := json.Marshal(args)
|
||||
ch <- provider.Chunk{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: id, Name: name, Arguments: string(raw)}}
|
||||
}
|
||||
if strings.Contains(firstUser(req), isolationRuntimePrompt) {
|
||||
r.requests = append(r.requests, isolationRuntimeRequest{model: p.model, effort: p.effort, request: req})
|
||||
// A finite scripted ceiling bounds the negative control even without a cap.
|
||||
if len(r.requests) >= 10 {
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "inspection finished"}
|
||||
} else {
|
||||
call(fmt.Sprintf("read-%d", len(r.requests)), "read_file", map[string]string{"path": fmt.Sprintf("a-%d.txt", len(r.requests))})
|
||||
}
|
||||
} else if hasToolResult(req) {
|
||||
r.result = lastToolResult(req)
|
||||
ch <- provider.Chunk{Type: provider.ChunkText, Text: "parent done"}
|
||||
} else if r.deferred {
|
||||
call("delegate", "use_capability", map[string]any{"action": "call", "capability_id": "task:subagent", "arguments": r.args})
|
||||
} else {
|
||||
call("delegate", "task", r.args)
|
||||
}
|
||||
ch <- provider.Chunk{Type: provider.ChunkDone}
|
||||
close(ch)
|
||||
return ch, nil
|
||||
}
|
||||
|
||||
func TestEffectIsolatedTaskHonorsRuntimeSelectionAndBudget(t *testing.T) {
|
||||
if _, err := exec.LookPath("git"); err != nil {
|
||||
t.Skip("git not installed")
|
||||
}
|
||||
cases := []struct {
|
||||
name, configuredModel, inheritedEffort, callModel, callEffort, parentEffort string
|
||||
wantModel, wantEffort string
|
||||
unsupported, deriveBudget, noParentCap, shared bool
|
||||
wantRequests int
|
||||
}{
|
||||
{name: "explicit", configuredModel: "child-model", callModel: "alternate-model", callEffort: "low", wantModel: "alternate", wantEffort: "low"},
|
||||
{name: "configured-model", configuredModel: "child-model", callEffort: "low", wantModel: "child", wantEffort: "low"},
|
||||
{name: "inherited-effort", configuredModel: "child-model", inheritedEffort: "low", wantModel: "child", wantEffort: "low"},
|
||||
{name: "parent-runtime-effort", parentEffort: "low", wantModel: "parent", wantEffort: "low"},
|
||||
{name: "unsupported-inherited-effort", callModel: "child-model", inheritedEffort: "max", wantModel: "child", wantEffort: "high"},
|
||||
{name: "unsupported-explicit-effort", configuredModel: "child-model", callEffort: "max", unsupported: true},
|
||||
{name: "unsupported-configured-pair", configuredModel: "child-model", inheritedEffort: "max", unsupported: true},
|
||||
{name: "derived-budget", configuredModel: "child-model", wantModel: "child", wantEffort: "high", deriveBudget: true, wantRequests: 7},
|
||||
{name: "automatic-budget", wantModel: "parent", wantEffort: "high", deriveBudget: true, noParentCap: true, wantRequests: 10},
|
||||
{name: "shared-parent-alias", callModel: "parent-model", parentEffort: "low", wantModel: "parent", wantEffort: "low", shared: true},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
for _, deferred := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/deferred=%t", tc.name, deferred), func(t *testing.T) {
|
||||
home, root := statedBootHome(t), testenv.TempDir(t)
|
||||
kind := uniqueKind("isolation-runtime")
|
||||
rec := &isolationRuntimeRecorder{deferred: deferred, args: map[string]any{
|
||||
"prompt": isolationRuntimePrompt, "isolation": "worktree", "max_steps": 1,
|
||||
}}
|
||||
if tc.deriveBudget {
|
||||
delete(rec.args, "max_steps")
|
||||
}
|
||||
if tc.shared {
|
||||
delete(rec.args, "isolation")
|
||||
}
|
||||
if tc.callModel != "" {
|
||||
rec.args["model"] = tc.callModel
|
||||
}
|
||||
if tc.callEffort != "" {
|
||||
rec.args["effort"] = tc.callEffort
|
||||
}
|
||||
provider.Register(kind, func(cfg provider.Config) (provider.Provider, error) {
|
||||
effort, _ := cfg.Extra["effort"].(string)
|
||||
return &isolationRuntimeProvider{recorder: rec, model: cfg.Model, effort: effort}, nil
|
||||
})
|
||||
var config strings.Builder
|
||||
fmt.Fprintf(&config, `
|
||||
default_model = "parent-model"
|
||||
[agent]
|
||||
system_prompt = "BASE"
|
||||
worktree_isolation = true
|
||||
subagent_model = %q
|
||||
subagent_effort = %q
|
||||
[codegraph]
|
||||
enabled = false
|
||||
`, tc.configuredModel, tc.inheritedEffort)
|
||||
for _, model := range []string{"parent", "child", "alternate"} {
|
||||
fmt.Fprintf(&config, `
|
||||
[[providers]]
|
||||
name = %q
|
||||
kind = %q
|
||||
model = %q
|
||||
supported_efforts = ["low", "high"]
|
||||
default_effort = "high"
|
||||
`, model+"-model", kind, model)
|
||||
}
|
||||
writeFile(t, root, "reasonix.toml", config.String())
|
||||
writeFile(t, root, "a.txt", "read me\n")
|
||||
for i := 1; i <= 10; i++ {
|
||||
writeFile(t, root, fmt.Sprintf("a-%d.txt", i), fmt.Sprintf("round %d\n", i))
|
||||
}
|
||||
gitInDir(t, root, "init", "-q")
|
||||
mirrorToUserConfigAt(t, home, root)
|
||||
gitInDir(t, root, "add", "-A")
|
||||
gitInDir(t, root, "commit", "-q", "-m", "init")
|
||||
opts := Options{Home: home, WorkspaceRoot: root, MaxSteps: 12, Sink: event.Discard}
|
||||
if tc.noParentCap {
|
||||
opts.MaxSteps = 0
|
||||
}
|
||||
if tc.parentEffort != "" {
|
||||
opts.EffortOverride = &tc.parentEffort
|
||||
}
|
||||
ctrl, err := Build(t.Context(), opts)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer ctrl.Close()
|
||||
ctrl.SetToolApprovalMode(control.ToolApprovalYolo)
|
||||
if err := ctrl.Run(t.Context(), "delegate the inspection"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec.mu.Lock()
|
||||
defer rec.mu.Unlock()
|
||||
if tc.unsupported {
|
||||
if len(rec.requests) != 0 || !strings.Contains(rec.result, "max") {
|
||||
t.Fatalf("unsupported effort silently executed: requests=%d result=%q", len(rec.requests), rec.result)
|
||||
}
|
||||
return
|
||||
}
|
||||
wantRequests := tc.wantRequests
|
||||
if wantRequests == 0 {
|
||||
wantRequests = 2
|
||||
}
|
||||
if len(rec.requests) != wantRequests {
|
||||
t.Errorf("child request count = %d, want %d; result=%q", len(rec.requests), wantRequests, rec.result)
|
||||
}
|
||||
for _, got := range rec.requests {
|
||||
if got.model != tc.wantModel || got.effort != tc.wantEffort {
|
||||
t.Errorf("child runtime = model %q effort %q; want %q/%q", got.model, got.effort, tc.wantModel, tc.wantEffort)
|
||||
}
|
||||
}
|
||||
if len(rec.requests) > 1 {
|
||||
first, _ := json.Marshal(rec.requests[0].request.Tools)
|
||||
last, _ := json.Marshal(rec.requests[len(rec.requests)-1].request.Tools)
|
||||
if string(first) != string(last) || len(rec.requests[0].request.Tools) == 0 {
|
||||
t.Fatal("child tool definitions changed at finalization")
|
||||
}
|
||||
initial := rec.requests[0].request.Messages[0]
|
||||
final := rec.requests[len(rec.requests)-1].request.Messages[0]
|
||||
if initial.Role != provider.RoleSystem || final.Role != provider.RoleSystem || initial.Content != final.Content {
|
||||
t.Fatal("child system prefix changed during the run")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,7 +56,11 @@ func (b *builder) addIsolation() {
|
||||
b.tools.reg.Add(isolation.NewDiscardTool(store))
|
||||
parent, sink := b.opts, b.sink
|
||||
b.tools.taskTool.SetIsolation(store, b.root, func(ctx context.Context, run isolation.Run) (isolation.Outcome, error) {
|
||||
answer, unverified, err := runUnattended(ctx, parent, isolatedUsage{sink}, posture.mode(), run.WorkspaceRoot, run.Prompt, run.Model)
|
||||
_, selection, err := subagentProviderSelection(b.cfg, b.providers.effective, b.model.entry, run.Model, run.Effort)
|
||||
if err != nil {
|
||||
return isolation.Outcome{}, err
|
||||
}
|
||||
answer, unverified, err := runUnattended(ctx, parent, isolatedUsage{sink}, posture.mode(), run.WorkspaceRoot, run.Prompt, selection, run.MaxSteps)
|
||||
return isolation.Outcome{Answer: answer, Unverified: unverified}, err
|
||||
})
|
||||
// The registry canonicalizes a schema when a tool is added, and task was
|
||||
|
||||
@@ -99,29 +99,11 @@ func newSubagentConfig(opts Options, cfg *config.Config, entry *config.ProviderE
|
||||
inherited := resolveInheritedSubagentEffort(cfg, entry)
|
||||
return subagentConfig{
|
||||
resolveProvider: func(modelRef, effort string) (provider.Provider, *provider.Pricing, int, error) {
|
||||
me, selectedRef, err := subagentModelEntry(cfg, resolver, entry, modelRef)
|
||||
me, selection, err := subagentProviderSelection(cfg, resolver, entry, modelRef, effort)
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
var effortOverride *string
|
||||
if selectedRef == modelRefFromEntry(entry) {
|
||||
effortOverride = &me.Effort
|
||||
}
|
||||
if strings.TrimSpace(effort) != "" {
|
||||
normalized, err := config.NormalizeEffort(&me, effort)
|
||||
if err != nil {
|
||||
if resolver == nil {
|
||||
return nil, nil, 0, fmt.Errorf("subagent effort %q on %s: %w", effort, selectedRef, err)
|
||||
}
|
||||
normalized = effort
|
||||
}
|
||||
me.Effort = normalized
|
||||
effortOverride = &normalized
|
||||
if me.Kind == "anthropic" && strings.TrimSpace(me.Effort) != "" && strings.TrimSpace(me.Thinking) == "" {
|
||||
me.Thinking = "adaptive"
|
||||
}
|
||||
}
|
||||
p, err := resolveProvider(resolver, cfg, proxy, provider.Selection{Ref: selectedRef, Effort: effortOverride})
|
||||
p, err := resolveProvider(resolver, cfg, proxy, selection)
|
||||
if err != nil {
|
||||
return nil, nil, 0, err
|
||||
}
|
||||
@@ -148,6 +130,32 @@ func newSubagentConfig(opts Options, cfg *config.Config, entry *config.ProviderE
|
||||
}
|
||||
}
|
||||
|
||||
func subagentProviderSelection(cfg *config.Config, resolver provider.Resolver, parent *config.ProviderEntry, modelRef, effort string) (config.ProviderEntry, provider.Selection, error) {
|
||||
entry, selectedRef, err := subagentModelEntry(cfg, resolver, parent, modelRef)
|
||||
if err != nil {
|
||||
return entry, provider.Selection{}, err
|
||||
}
|
||||
selection := provider.Selection{Ref: selectedRef}
|
||||
if selectedRef == modelRefFromEntry(parent) {
|
||||
selection.Effort = &entry.Effort
|
||||
}
|
||||
if strings.TrimSpace(effort) != "" {
|
||||
normalized, err := config.NormalizeEffort(&entry, effort)
|
||||
if err != nil {
|
||||
if resolver == nil {
|
||||
return entry, selection, fmt.Errorf("subagent effort %q on %s: %w", effort, selectedRef, err)
|
||||
}
|
||||
normalized = effort
|
||||
}
|
||||
entry.Effort = normalized
|
||||
selection.Effort = &normalized
|
||||
if entry.Kind == "anthropic" && strings.TrimSpace(entry.Effort) != "" && strings.TrimSpace(entry.Thinking) == "" {
|
||||
entry.Thinking = "adaptive"
|
||||
}
|
||||
}
|
||||
return entry, selection, nil
|
||||
}
|
||||
|
||||
func subagentModelEntry(cfg *config.Config, resolver provider.Resolver, parent *config.ProviderEntry, ref string) (config.ProviderEntry, string, error) {
|
||||
ref = childModelRef(cfg, parent, ref)
|
||||
if parent != nil && (ref == "" || ref == modelRefFromEntry(parent)) {
|
||||
@@ -155,6 +163,9 @@ func subagentModelEntry(cfg *config.Config, resolver provider.Resolver, parent *
|
||||
}
|
||||
if cfg != nil {
|
||||
if resolved, ok := cfg.ResolveModel(ref); ok {
|
||||
if parent != nil && modelRefFromEntry(resolved) == modelRefFromEntry(parent) {
|
||||
return *parent, modelRefFromEntry(parent), nil
|
||||
}
|
||||
return *resolved, modelRefFromEntry(resolved), nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package boot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/contract/config"
|
||||
)
|
||||
|
||||
func TestSubagentSelectionPreservesRuntimeForParentAliases(t *testing.T) {
|
||||
cfg := config.Default()
|
||||
cfg.Providers = []config.ProviderEntry{{Name: "parent", Kind: "openai", Models: []string{"p", "q"}, Default: "p",
|
||||
SupportedEfforts: []string{"low", "high"}, DefaultEffort: "high"}}
|
||||
parent, ok := cfg.ResolveModel("parent/p")
|
||||
if !ok {
|
||||
t.Fatal("parent model missing")
|
||||
}
|
||||
parent.Effort = "low"
|
||||
for _, ref := range []string{"", "parent", "parent/p", "p"} {
|
||||
t.Run(ref, func(t *testing.T) {
|
||||
_, selection, err := subagentProviderSelection(cfg, nil, parent, ref, "")
|
||||
if err != nil || selection.Ref != "parent/p" || selection.Effort == nil || *selection.Effort != "low" {
|
||||
t.Fatalf("parent runtime selection for %q = %+v, %v", ref, selection, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
_, other, err := subagentProviderSelection(cfg, nil, parent, "parent/q", "")
|
||||
if err != nil || other.Ref != "parent/q" || other.Effort != nil {
|
||||
t.Fatalf("another model inherited the parent's override: %+v, %v", other, err)
|
||||
}
|
||||
_, explicit, err := subagentProviderSelection(cfg, nil, parent, "parent", "high")
|
||||
if err != nil || explicit.Effort == nil || *explicit.Effort != "high" {
|
||||
t.Fatalf("explicit effort did not narrow the inherited selection: %+v, %v", explicit, err)
|
||||
}
|
||||
}
|
||||
@@ -35,8 +35,9 @@ func (t *TaskTool) SetIsolation(store *isolation.Store, root string, runner isol
|
||||
// isolatedCall is the part of a task call an isolated run reads, and the
|
||||
// arguments it has to refuse rather than silently drop.
|
||||
type isolatedCall struct {
|
||||
mode, prompt, model string
|
||||
unsupported []string
|
||||
mode, prompt, model, effort string
|
||||
maxSteps int
|
||||
unsupported []string
|
||||
}
|
||||
|
||||
func (t *TaskTool) executeIsolated(ctx context.Context, call isolatedCall) (string, error) {
|
||||
@@ -50,7 +51,17 @@ func (t *TaskTool) executeIsolated(ctx context.Context, call isolatedCall) (stri
|
||||
return "", tool.Refusal{Code: codeIsolationUnsupported, Message: "an isolated task runs as its own agent and cannot take " +
|
||||
strings.Join(call.unsupported, ", ") + "; drop them or run without isolation"}
|
||||
}
|
||||
return t.isolated.store.Execute(ctx, t.isolated.runner, t.isolated.root, call.prompt, strings.TrimSpace(call.model))
|
||||
model, effort := t.resolveModelEffort("", "", call.model, call.effort, "", "")
|
||||
if model == "" {
|
||||
model = t.baseModel
|
||||
}
|
||||
model, effort = t.childRuntimeRef(ctx, model, effort)
|
||||
steps := t.childMaxStepsForContext(ctx, call.maxSteps)
|
||||
runner := func(ctx context.Context, run isolation.Run) (isolation.Outcome, error) {
|
||||
run.Effort, run.MaxSteps = effort, steps
|
||||
return t.isolated.runner(ctx, run)
|
||||
}
|
||||
return t.isolated.store.Execute(ctx, runner, t.isolated.root, call.prompt, model)
|
||||
}
|
||||
|
||||
// isolatedUnsupported names the task arguments that were set and that an
|
||||
|
||||
@@ -418,7 +418,7 @@ func (t *TaskTool) executeTask(ctx context.Context, args json.RawMessage) (strin
|
||||
return "", fmt.Errorf("prompt is required")
|
||||
}
|
||||
if p.Isolation != "" {
|
||||
return t.executeIsolated(ctx, isolatedCall{mode: p.Isolation, prompt: p.Prompt, model: p.Model,
|
||||
return t.executeIsolated(ctx, isolatedCall{mode: p.Isolation, prompt: p.Prompt, model: p.Model, effort: p.Effort, maxSteps: p.MaxSteps,
|
||||
unsupported: isolatedUnsupported(p.Profile, p.WritePaths, p.Tools, p.ContinueFrom, p.ForkFrom, p.RunInBackground)})
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@ type Run struct {
|
||||
WorkspaceRoot string
|
||||
Prompt string
|
||||
Model string
|
||||
Effort string
|
||||
MaxSteps int
|
||||
}
|
||||
|
||||
// Outcome is what the run ended with. Unverified carries the readiness reason
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
## 修复
|
||||
|
||||
- worktree 隔离任务遵守显式或继承的模型、effort 与步数上限;同模型别名保留
|
||||
父会话实际 effort。原本不支持的工具/路径限制、后台和续跑仍明确拒绝。Refs #14025
|
||||
- Worktree tasks honor model, effort and step caps. Parent aliases preserve
|
||||
session effort; unsupported restrictions and lifecycle arguments remain
|
||||
refused. Refs #14025
|
||||
Reference in new issue
Block a user