mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-12 20:15:42 +00:00
* feat(collaboration): isolate delegated artifacts and child runs Isolate delegated inputs and outputs behind secure artifact exchange lifecycles. Scope Agent Link tasks by tenant and root agent, and enforce delegation spawn-tree boundaries. Add process-wide child-run admission and preserve logical media paths across native, MCP, and sandbox execution. * fix(collaboration): harden delegated task isolation Enforce tenant and root-agent task scope across migrations and stores. Add exactly-once async completion delivery, delegated sandbox boundaries, and confined artifact and media recovery across runtime surfaces. * fix(collaboration): recover interrupted async tasks * fix(collaboration): normalize persisted child-run status --------- Co-authored-by: ntduc <ntduc@cpp.ai.vn>
145 lines
6.0 KiB
Go
145 lines
6.0 KiB
Go
package tools
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
|
|
"github.com/nextlevelbuilder/goclaw/internal/providers"
|
|
"github.com/nextlevelbuilder/goclaw/internal/store"
|
|
usagecaps "github.com/nextlevelbuilder/goclaw/internal/usage/caps"
|
|
)
|
|
|
|
// callTrackingProvider records whether Chat/ChatStream ran, so a test can prove
|
|
// the pre-transport guard blocked BEFORE any transport call.
|
|
type callTrackingProvider struct {
|
|
called bool
|
|
}
|
|
|
|
func (p *callTrackingProvider) Name() string { return "tracking" }
|
|
func (p *callTrackingProvider) DefaultModel() string { return "claude-sonnet-4-5-20250929" }
|
|
func (p *callTrackingProvider) Chat(_ context.Context, _ providers.ChatRequest) (*providers.ChatResponse, error) {
|
|
p.called = true
|
|
return &providers.ChatResponse{Content: "ok", FinishReason: "stop"}, nil
|
|
}
|
|
func (p *callTrackingProvider) ChatStream(_ context.Context, _ providers.ChatRequest, _ func(providers.StreamChunk)) (*providers.ChatResponse, error) {
|
|
p.called = true
|
|
return &providers.ChatResponse{Content: "ok", FinishReason: "stop"}, nil
|
|
}
|
|
|
|
func subagentBigMessage(model string, targetChars int) providers.Message {
|
|
return providers.Message{Role: "user", Content: strings.Repeat("word ", targetChars)}
|
|
}
|
|
|
|
// TestSubagentSpawn_CapturesOriginContextWindow proves the Spawn path captures
|
|
// the CALLING agent's context window (set in ctx by the agent loop's
|
|
// injectContext) into SubagentTask.OriginContextWindow — the value the guard
|
|
// later uses. This locks the plumbing the previous round only claimed.
|
|
func TestSubagentSpawn_CapturesOriginContextWindow(t *testing.T) {
|
|
provider := &recordingSubagentProvider{}
|
|
manager := NewSubagentManager(provider, nil, "manager-default", nil, NewRegistry, SubagentConfig{
|
|
MaxConcurrent: 4, MaxSpawnDepth: 3, MaxChildrenPerAgent: 8,
|
|
})
|
|
manager.SetAgentBudget(200_000, 32_000) // manager default (would be wrong for a 128k caller)
|
|
|
|
// Calling agent configured at 128k/8192, propagated via ctx (as injectContext does).
|
|
ctx := store.WithAgentContextWindow(context.Background(), 128_000)
|
|
ctx = store.WithAgentMaxTokens(ctx, 8_192)
|
|
ctx = store.WithTenantID(ctx, uuid.New())
|
|
ctx = store.WithAgentID(ctx, uuid.New())
|
|
ctx = WithToolAgentKey(ctx, "parent")
|
|
|
|
_, _, _, err := manager.RunSync(ctx, "parent", 0, "task", "label", "", "chan", "chat")
|
|
if err != nil {
|
|
t.Fatalf("RunSync error: %v", err)
|
|
}
|
|
|
|
// Find the task the manager created and assert it captured the CALLER's
|
|
// 128k/8192 budget, not the manager default 200k/32000.
|
|
manager.mu.RLock()
|
|
defer manager.mu.RUnlock()
|
|
if len(manager.tasks) == 0 {
|
|
t.Fatal("no subagent task recorded")
|
|
}
|
|
for _, task := range manager.tasks {
|
|
if task.OriginContextWindow != 128_000 {
|
|
t.Fatalf("OriginContextWindow = %d, want 128000 (calling agent, not manager default)", task.OriginContextWindow)
|
|
}
|
|
if task.OriginMaxTokens != 8_192 {
|
|
t.Fatalf("OriginMaxTokens = %d, want 8192 (calling agent, not manager default)", task.OriginMaxTokens)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestChatSubagentWithUsageCap_HonorsOriginWindow is the production-path guard
|
|
// for finding #4: a subagent request that fits the 200k model window but exceeds
|
|
// the calling agent's 128k cap must be blocked before transport.
|
|
func TestChatSubagentWithUsageCap_HonorsOriginWindow(t *testing.T) {
|
|
model := "claude-sonnet-4-5-20250929" // 200k model window, cl100k tokenizer
|
|
manager := NewSubagentManager(nil, nil, "manager-default", nil, NewRegistry, SubagentConfig{})
|
|
// Manager default is 200k; the per-task origin window must override it.
|
|
manager.SetAgentBudget(200_000, 32_000)
|
|
|
|
// ~140k tokens: fits 200k model window, exceeds a 128k agent cap.
|
|
req := providers.ChatRequest{
|
|
Model: model,
|
|
Messages: []providers.Message{subagentBigMessage(model, 140_000)},
|
|
Options: map[string]any{providers.OptMaxTokens: 4096},
|
|
}
|
|
|
|
// Task from a 128k-configured calling agent.
|
|
task128 := &SubagentTask{ID: "t128", OriginContextWindow: 128_000, OriginMaxTokens: 8_192}
|
|
prov := &callTrackingProvider{}
|
|
_, err := manager.chatSubagentWithUsageCap(context.Background(), task128, prov, model, req, 0, 0)
|
|
if err == nil {
|
|
t.Fatal("expected abort: 140k request exceeds 128k origin window")
|
|
}
|
|
if prov.called {
|
|
t.Fatal("provider must NOT be called when the guard aborts (transport calls must be 0)")
|
|
}
|
|
var ctxErr *usagecaps.ContextWindowExceededError
|
|
if !errors.As(err, &ctxErr) {
|
|
t.Fatalf("expected *ContextWindowExceededError, got %T: %v", err, err)
|
|
}
|
|
if ctxErr.ContextWindow != 128_000 {
|
|
t.Fatalf("guard used window %d, want 128000 (origin, not model/manager)", ctxErr.ContextWindow)
|
|
}
|
|
}
|
|
|
|
// TestChatSubagentWithUsageCap_TwoAgentsIsolated proves two subagents from
|
|
// agents configured at 128k and 200k are guarded independently by ONE shared
|
|
// manager: the same 140k request is blocked for the 128k caller but allowed for
|
|
// the 200k caller.
|
|
func TestChatSubagentWithUsageCap_TwoAgentsIsolated(t *testing.T) {
|
|
model := "claude-sonnet-4-5-20250929"
|
|
manager := NewSubagentManager(nil, nil, "manager-default", nil, NewRegistry, SubagentConfig{})
|
|
manager.SetAgentBudget(200_000, 32_000)
|
|
|
|
req := providers.ChatRequest{
|
|
Model: model,
|
|
Messages: []providers.Message{subagentBigMessage(model, 140_000)},
|
|
Options: map[string]any{providers.OptMaxTokens: 4096},
|
|
}
|
|
|
|
// 128k caller: blocked.
|
|
prov128 := &callTrackingProvider{}
|
|
if _, err := manager.chatSubagentWithUsageCap(context.Background(), &SubagentTask{ID: "a128", OriginContextWindow: 128_000, OriginMaxTokens: 8_192}, prov128, model, req, 0, 0); err == nil {
|
|
t.Fatal("128k caller: expected abort for 140k request")
|
|
}
|
|
if prov128.called {
|
|
t.Fatal("128k caller: provider must not be called")
|
|
}
|
|
|
|
// 200k caller: allowed (140k < 200k), provider reached.
|
|
prov200 := &callTrackingProvider{}
|
|
if _, err := manager.chatSubagentWithUsageCap(context.Background(), &SubagentTask{ID: "a200", OriginContextWindow: 200_000, OriginMaxTokens: 8_192}, prov200, model, req, 0, 0); err != nil {
|
|
t.Fatalf("200k caller: expected allow for 140k request, got %v", err)
|
|
}
|
|
if !prov200.called {
|
|
t.Fatal("200k caller: provider should have been called")
|
|
}
|
|
}
|