feat(piagent): batch-generate images in one call when generators share a prompt

When multiple image-generator tasks carry the same brief, request all
images from a single GPT Image call with Count=N instead of firing one
call per image. Adds directImageBatchCount/Prompt detection, batch
(incremental) generation paths, and folds the planned count into the
idempotency key. Disables the streaming plan probe for multi-image
requests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-09 09:05:18 +08:00
parent 8a28a0bcea
commit cc30ae4284
4 changed files with 223 additions and 5 deletions
@@ -270,6 +270,64 @@ func TestDesignAgentImageGeneratorModeCallsImageModelDirectly(t *testing.T) {
}
}
func TestDesignAgentImageGeneratorModeBatchesSamePromptCount(t *testing.T) {
runner := &recordingRunner{}
creative := &recordingCreativeAgent{}
imageGenerator := &fakeImageGenerator{}
agent := NewDesignAgent(runner, imageGenerator, creative)
var emitted []design.Node
plan, err := agent.PlanIncremental(context.Background(), design.AgentRequest{
Project: design.Project{
ID: "project-image-generator",
Nodes: []design.Node{
{
ID: "generator-1",
Type: design.NodeTypeImage,
Tone: "image-generator",
Status: "generating",
LayerRole: "image-generator",
Width: 512,
Height: 512,
},
},
},
Prompt: "画一只蓝色小鸟",
Mode: "image-generator",
ImageModel: "gpt-image-2",
ImageSize: "1024x1024",
TaskPlan: design.AgentTaskPlan{
Intent: "image",
ImageCount: 2,
ImageTasks: []design.AgentImageTask{
{Title: "蓝色小鸟 方案 1", Brief: "画一只蓝色小鸟"},
{Title: "蓝色小鸟 方案 2", Brief: "画一只蓝色小鸟"},
},
},
}, func(result design.AgentImageGenerationResult) error {
emitted = append(emitted, result.Node)
return nil
})
if err != nil {
t.Fatal(err)
}
if runner.calls != 0 || creative.buildCalls != 0 {
t.Fatalf("expected direct image generator mode, runner=%d build=%d", runner.calls, creative.buildCalls)
}
if imageGenerator.callCount() != 1 {
t.Fatalf("expected one batched image request, got %d", imageGenerator.callCount())
}
if imageGenerator.opts.Count != 2 {
t.Fatalf("expected image request n/count 2, got %#v", imageGenerator.opts)
}
if len(emitted) != 2 || len(plan.GeneratedNodes) != 2 {
t.Fatalf("expected two generated nodes, emitted=%#v plan=%#v", emitted, plan.GeneratedNodes)
}
if plan.GeneratedNodes[0].ID != "generator-1" || plan.GeneratedNodes[1].ID == "generator-1" {
t.Fatalf("expected first node to replace generator and second to be separate, got %#v", plan.GeneratedNodes)
}
}
func (g *fakeImageGenerator) callCount() int {
g.mu.Lock()
defer g.mu.Unlock()
@@ -635,6 +693,7 @@ func TestGeneratedImageNodeFromImageGeneratorSlotBecomesRegularImage(t *testing.
Tone: "image-generator",
Status: "generating",
LayerRole: "image-generator",
ParentID: "generator-parent",
Width: 320,
Height: 240,
},
@@ -647,6 +706,9 @@ func TestGeneratedImageNodeFromImageGeneratorSlotBecomesRegularImage(t *testing.
if node.LayerRole != "" || node.Tone != "visual" || node.Status != "success" {
t.Fatalf("expected regular completed image node, got %#v", node)
}
if node.ParentID != "" {
t.Fatalf("expected completed image generator node to clear parent id, got %#v", node)
}
}
func TestDesignAgentDoesNotRunFallbackAfterImageTimeout(t *testing.T) {