Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions cmd/odek/subagent_model_integration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package main

import (
"bytes"
"context"
"encoding/json"
"math"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
)

// TestE2E_SubagentTaskModelReachesProvider verifies the task envelope's model
// survives the real subprocess boundary and is used in the provider request.
// This is intentionally E2E-gated because it builds/runs the odek binary.
func TestE2E_SubagentTaskModelReachesProvider(t *testing.T) {
skipIfNoE2E(t)
requestModels := make(chan string, 16)
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models") {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"data":[]}`))
return
}
var body struct {
Model string `json:"model"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
requestModels <- body.Model
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"done"},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":20}}`))
}))
defer provider.Close()

fixture := t.TempDir()
configDir := filepath.Join(fixture, ".odek")
if err := os.Mkdir(configDir, 0700); err != nil {
t.Fatal(err)
}
config := `{"model":"operator-model","memory":{"enabled":false},"limits":{"model_prices":{"task-model":{"input_cost_per_million_usd":2,"output_cost_per_million_usd":4},"operator-model":{"input_cost_per_million_usd":20,"output_cost_per_million_usd":40}}}}`
if err := os.WriteFile(filepath.Join(configDir, "config.json"), []byte(config), 0600); err != nil {
t.Fatal(err)
}
taskPath := filepath.Join(fixture, "task.json")
spec := taskFileSpec{Goal: "reply briefly", Provider: "deepseek", Model: "task-model", BaseURL: provider.URL}
b, err := json.Marshal(spec)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(taskPath, b, 0600); err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, e2eBinary, "subagent", "--task", taskPath, "--quiet")
cmd.Env = append(os.Environ(), "ODEK_API_KEY=test-key", "ODEK_NO_SANDBOX=1", "HOME="+fixture, "USERPROFILE="+fixture)
cmd.Dir = fixture
var stdout, stderr bytes.Buffer
cmd.Stdout, cmd.Stderr = &stdout, &stderr
if err := cmd.Run(); err != nil {
t.Fatalf("subagent failed: %v\nstdout=%s\nstderr=%s", err, stdout.String(), stderr.String())
}
select {
case requestModel := <-requestModels:
if requestModel != "task-model" {
t.Fatalf("provider received model %q, want task-model", requestModel)
}
default:
t.Fatal("no provider request received")
}
var result subagentResult
if err := json.Unmarshal(stdout.Bytes(), &result); err != nil {
t.Fatalf("invalid result: %v: %s", err, stdout.String())
}
if result.Status != "success" {
t.Fatalf("child failed: %+v", result)
}
if math.Abs(result.CostUSD-0.00028) > 1e-10 {
t.Fatalf("child cost = %g, want 0.00028 from selected model prices; result=%+v", result.CostUSD, result)
}
}
104 changes: 104 additions & 0 deletions cmd/odek/subagent_model_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package main

import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"time"
)

func TestDelegateTasksPerTaskModelInTaskEnvelope(t *testing.T) {
dir := t.TempDir()
marker := filepath.Join(dir, "envelopes")
if err := os.Mkdir(marker, 0700); err != nil {
t.Fatal(err)
}
t.Setenv("ODEK_MODEL_MARKER", marker)
script := filepath.Join(dir, "child")
const body = `#!/bin/sh
task=""
while [ "$#" -gt 0 ]; do
if [ "$1" = "--task" ]; then shift; task="$1"; fi
shift
done
id=$(sed -n 's/.*"task_id":"\([^"]*\)".*/\1/p' "$task")
cp "$task" "$ODEK_MODEL_MARKER/$id.json"
echo '{"status":"success","summary":"ok"}'
`
if err := os.WriteFile(script, []byte(body), 0755); err != nil {
t.Fatal(err)
}
tool := &delegateTasksTool{maxConcurrency: 3, odekPath: script, timeout: 10 * time.Second, provider: "parent-provider", model: "parent-model", baseURL: "https://provider.invalid/v1"}
args := `{"tasks":[{"goal":"inherited","provider":"evil","base_url":"https://evil.invalid"},{"goal":"chosen","model":"fast-child"},{"goal":"another","model":"cheap-child"}]}`
if _, err := tool.Call(args); err != nil {
t.Fatalf("delegate call failed: %v", err)
}
entries, err := os.ReadDir(marker)
if err != nil {
t.Fatal(err)
}
if len(entries) != 3 {
t.Fatalf("captured %d child envelopes, want 3", len(entries))
}
models := make(map[string]bool)
for _, entry := range entries {
data, err := os.ReadFile(filepath.Join(marker, entry.Name()))
if err != nil {
t.Fatal(err)
}
var envelope taskEnvelope
if err := json.Unmarshal(data, &envelope); err != nil {
t.Fatal(err)
}
if envelope.Provider != "parent-provider" || envelope.BaseURL != "https://provider.invalid/v1" {
t.Errorf("parent connection fields changed: %+v", envelope)
}
var expected string
switch envelope.Goal {
case "inherited":
expected = "parent-model"
case "chosen":
expected = "fast-child"
case "another":
expected = "cheap-child"
default:
t.Errorf("unexpected child goal %q", envelope.Goal)
}
if envelope.Model != expected {
t.Errorf("goal %q got model %q, want %q", envelope.Goal, envelope.Model, expected)
}
models[envelope.Model] = true
}
for _, model := range []string{"parent-model", "fast-child", "cheap-child"} {
if !models[model] {
t.Errorf("child envelopes missing model %q: %v", model, models)
}
}
if tool.model != "parent-model" {
t.Fatalf("parent model mutated: %q", tool.model)
}
}

func TestDelegateTasksRejectsInvalidModelNamesBeforeSpawn(t *testing.T) {
var spawned atomic.Int32
tool := &delegateTasksTool{maxConcurrency: 1, runTaskFn: func(int, string, string, string, string, string, string, string, string) string {
spawned.Add(1)
return `{"status":"success"}`
}}
for _, model := range []string{"", " ", "\nfast", "\tfast", "bad\x00name", strings.Repeat("x", 257)} {
argsBytes, err := json.Marshal(map[string]any{"tasks": []any{map[string]any{"goal": "test", "model": model}}})
if err != nil {
t.Fatal(err)
}
result, callErr := tool.Call(string(argsBytes))
if callErr != nil || !strings.Contains(result, `"error"`) {
t.Errorf("model %q: result=%s err=%v", model, result, callErr)
}
}
if spawned.Load() != 0 {
t.Fatalf("invalid model names spawned %d children", spawned.Load())
}
}
71 changes: 58 additions & 13 deletions cmd/odek/subagent_tool.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"strings"
"sync"
"time"
"unicode"

"github.com/BackendStack21/odek"
"github.com/BackendStack21/odek/internal/artifact"
Expand Down Expand Up @@ -204,7 +205,7 @@ func (t *delegateTasksTool) SetEventEmitter(fn func(events.Event)) {
}

func (t *delegateTasksTool) Description() string {
return `Spawn sub-agent processes for independent sub-tasks. Each child has a fresh context — put everything it needs in goal/context. Children never prompt for approvals (denials are listed). Trust never increases downward. Depth is capped.
return `Spawn sub-agent processes for independent sub-tasks. Each child has a fresh context — put everything it needs in goal/context. Children never prompt for approvals (denials are listed). Trust never increases downward. Depth is capped. Set a task's optional model to select another model from the same configured provider (for example, a faster model for a simple reviewer); omitted inherits the parent model.

Result delivery — two channels per sub-agent:
- Headline: the sub-agent's final answer, capped at ~2000 characters keeping the END (verdicts, next actions) — a leading … marks the cut.
Expand Down Expand Up @@ -249,6 +250,12 @@ func (t *delegateTasksTool) Schema() any {
"type": "string",
"description": "Optional. Operator-defined capability profile name (profiles config); its max_risk/allowlist/tool-filter override global config. Call list_subagent_profiles first to pick one. Omitted = subagent.default_profile.",
},
"model": map[string]any{
"type": "string",
"minLength": 1,
"maxLength": 256,
"description": "Optional. Model name for this child task. Omitted = inherit the parent model.",
},
},
"required": []string{"goal"},
},
Expand All @@ -270,12 +277,13 @@ func (t *delegateTasksTool) Call(args string) (string, error) {

var input struct {
Tasks []struct {
Goal string `json:"goal"`
Context string `json:"context"`
Guidance string `json:"guidance,omitempty"`
TrustLevel string `json:"trust_level,omitempty"`
MaxRisk string `json:"max_risk,omitempty"`
Profile string `json:"profile,omitempty"`
Goal string `json:"goal"`
Context string `json:"context"`
Guidance string `json:"guidance,omitempty"`
TrustLevel string `json:"trust_level,omitempty"`
MaxRisk string `json:"max_risk,omitempty"`
Profile string `json:"profile,omitempty"`
Model *string `json:"model,omitempty"`
} `json:"tasks"`
Description string `json:"description,omitempty"`
}
Expand All @@ -288,6 +296,17 @@ func (t *delegateTasksTool) Call(args string) (string, error) {
if len(input.Tasks) > 8 {
return `{"error":"max 8 tasks per call"}`, nil
}
selectedModels := make([]string, len(input.Tasks))
for i, task := range input.Tasks {
if task.Model == nil {
continue
}
model, err := validateSubagentModel(*task.Model)
if err != nil {
return fmt.Sprintf(`{"error":"task %d model: %v"}`, i+1, err), nil
}
selectedModels[i] = model
}
// Trust is provenance-derived, not model-declarable. Once this run has
// ingested external content, a tool call cannot label attacker-derived
// goal/context as trusted to recover MCP access or a looser child policy.
Expand Down Expand Up @@ -342,11 +361,9 @@ func (t *delegateTasksTool) Call(args string) (string, error) {
}
t.acquireSem(sem, emitFn, i)
run := t.runTaskFn
if run == nil {
run = t.runTask
}
model := selectedModels[i]
wg.Add(1)
go func(i int, taskID, goal, ctx, guidance, trust, maxRisk, profile, artifactDir string) {
go func(i int, taskID, goal, ctx, guidance, trust, maxRisk, profile, artifactDir, model string) {
defer wg.Done()
defer func() { <-sem }()
defer func() {
Expand All @@ -356,11 +373,16 @@ func (t *delegateTasksTool) Call(args string) (string, error) {
mu.Unlock()
}
}()
r := run(i, taskID, goal, ctx, guidance, trust, maxRisk, profile, artifactDir)
var r string
if run != nil {
r = run(i, taskID, goal, ctx, guidance, trust, maxRisk, profile, artifactDir)
} else {
r = t.runTaskWithModel(i, taskID, goal, ctx, guidance, trust, maxRisk, profile, artifactDir, model)
}
mu.Lock()
results[i] = r
mu.Unlock()
}(i, taskID, task.Goal, task.Context, task.Guidance, task.TrustLevel, task.MaxRisk, task.Profile, dirs[i])
}(i, taskID, task.Goal, task.Context, task.Guidance, task.TrustLevel, task.MaxRisk, task.Profile, dirs[i], model)
}

// Wait for every goroutine. Never refill a shared limiter's slots to
Expand Down Expand Up @@ -432,6 +454,10 @@ func (t *delegateTasksTool) chargeParentUsage(tokens int64) {
}

func (t *delegateTasksTool) runTask(taskIdx int, taskID, goal, taskContext, guidance, trustLevel, maxRisk, profile, artifactDir string) string {
return t.runTaskWithModel(taskIdx, taskID, goal, taskContext, guidance, trustLevel, maxRisk, profile, artifactDir, "")
}

func (t *delegateTasksTool) runTaskWithModel(taskIdx int, taskID, goal, taskContext, guidance, trustLevel, maxRisk, profile, artifactDir, model string) string {
// Parent-side fail-closed validation: an unknown profile name must
// fail the task BEFORE a child is spawned — the tool schema promises
// "unknown names fail the task", and a silently-bare child would run
Expand Down Expand Up @@ -480,6 +506,9 @@ func (t *delegateTasksTool) runTask(taskIdx int, taskID, goal, taskContext, guid
task.ArtifactRoot = artifactDir
task.Provider = t.provider
task.Model = t.model
if model != "" {
task.Model = model
}
task.BaseURL = t.baseURL
if err := json.NewEncoder(taskFile).Encode(task); err != nil {
taskFile.Close()
Expand Down Expand Up @@ -1080,6 +1109,22 @@ func newTaskEnvelope(taskID, goal, context, guidance, trustLevel, maxRisk, profi
}
}

func validateSubagentModel(model string) (string, error) {
for _, r := range model {
if r < 0x20 || r == 0x7f || unicode.IsControl(r) {
return "", errors.New("contains control characters")
}
}
model = strings.TrimSpace(model)
if model == "" {
return "", errors.New("must be non-empty")
}
if len([]rune(model)) > 256 {
return "", errors.New("is too long (maximum 256 characters)")
}
return model, nil
}

// subagentDeniedEvent is emitted on the runtime event stream for every
// policy denial observed by a child sub-agent.
// subagentWaitEventThreshold is how long a task may queue on the shared
Expand Down
Loading
Loading