Add generate_image tool and show Codex images inline

This commit is contained in:
Ian Gulliver
2026-10-22 19:16:47 -07:00
parent ff589f954c
commit 2aba745531
4 changed files with 163 additions and 0 deletions
+22
View File
@@ -3,6 +3,7 @@ package agents
import ( import (
"context" "context"
"encoding/json" "encoding/json"
"fmt"
"log/slog" "log/slog"
"os" "os"
"strings" "strings"
@@ -143,6 +144,16 @@ func (m *Manager) codexItemCompleted(ctx context.Context, l *live, s *codex.Sess
return nil return nil
} }
return m.emit(ctx, l, agentActor, "agent.thinking", map[string]any{"text": text}) return m.emit(ctx, l, agentActor, "agent.thinking", map[string]any{"text": text})
case codex.ThreadItemTypeImageGeneration:
if item.SavedPath == "" || l.reviewID != "" || l.owner != "" {
return nil
}
path, err := copyToMedia(MediaDir(l.dir), string(item.SavedPath))
if err != nil {
return err
}
return m.emit(ctx, l, agentActor, "agent.message", map[string]any{"text": fmt.Sprintf("![%s](<%s>)", imageAlt(item.RevisedPrompt), path)})
} }
if !codexToolItems[item.Type] { if !codexToolItems[item.Type] {
@@ -188,6 +199,17 @@ func (m *Manager) codexItemCompleted(ctx context.Context, l *live, s *codex.Sess
return nil return nil
} }
func imageAlt(prompt string) string {
alt := strings.NewReplacer("[", "", "]", "", "\n", " ").Replace(strings.TrimSpace(prompt))
if r := []rune(alt); len(r) > 120 {
alt = string(r[:120]) + "…"
}
if alt == "" {
return "generated image"
}
return alt
}
func codexToolResult(item codex.ThreadItem) (string, bool) { func codexToolResult(item codex.ThreadItem) (string, bool) {
status := codexStatus(item) status := codexStatus(item)
switch item.Type { switch item.Type {
+125
View File
@@ -0,0 +1,125 @@
package agents
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
"g.fc.run/ai/byre/internal/codex"
"g.fc.run/ai/byre/internal/mcp"
)
const imageGenGuidance = "You make images for another agent. Call your image_gen tool exactly once with the prompt you are given, unchanged, then reply with only the word done."
func (m *Manager) generateImageTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "generate_image",
Description: "Generate an image (a photo, illustration, texture, sprite, mockup) with OpenAI's image model through byre's Codex sign-in, saved as a PNG in the task's media folder. " +
"The reply gives its path: show it inline by embedding it in your final message as a Markdown image, ![what it shows](<path>), or copy it into the worktree when the project uses it. " +
"Use it whenever the human asks for a picture, rather than drawing one in SVG or HTML. It takes a minute or two.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"prompt": {"type": "string", "description": "What to draw: subject, style, composition, lighting, and any exact text."}
},
"required": ["prompt"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
return m.generateImage(ctx, l, raw)
},
}
}
func (m *Manager) generateImage(ctx context.Context, l *live, raw json.RawMessage) (string, error) {
args := struct {
Prompt string `json:"prompt"`
}{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed generate_image call: %w", err)
}
if strings.TrimSpace(args.Prompt) == "" {
return "", fmt.Errorf("generate_image needs a prompt")
}
media := MediaDir(l.dir)
err = os.MkdirAll(m.codexHome, 0o700)
if err != nil {
return "", err
}
s, err := codex.Start(ctx, codex.Options{
Cwd: media,
Env: []string{"CODEX_HOME=" + m.codexHome},
Guidance: imageGenGuidance,
})
if err != nil {
return "", fmt.Errorf("start codex: %w", err)
}
defer s.Close()
_, err = s.Send(ctx, args.Prompt)
if err != nil {
return "", fmt.Errorf("start codex turn: %w", err)
}
saved := ""
for {
select {
case <-ctx.Done():
s.Kill()
return "", fmt.Errorf("no image after %s", mcp.Timeout(ctx))
case ev, ok := <-s.Events():
if !ok {
return "", fmt.Errorf("codex exited before the image was made")
}
switch ev.Method {
case codex.MethodItemCompleted:
n := codex.ItemCompletedNotification{}
err := json.Unmarshal(ev.Params, &n)
if err != nil {
return "", err
}
if n.Item.Type != codex.ThreadItemTypeImageGeneration {
continue
}
if n.Item.Failure != nil {
return "", fmt.Errorf("image generation failed: %s (limit %s)", n.Item.Failure.Type, n.Item.Failure.LimitID)
}
saved = string(n.Item.SavedPath)
case codex.MethodTurnCompleted:
n := codex.TurnCompletedNotification{}
err := json.Unmarshal(ev.Params, &n)
if err != nil {
return "", err
}
if n.Turn.Error != nil {
return "", fmt.Errorf("codex: %s", n.Turn.Error.Message)
}
if saved == "" {
return "", fmt.Errorf("codex finished without making an image")
}
path, err := copyToMedia(media, saved)
if err != nil {
return "", err
}
return fmt.Sprintf("Saved %s", path), nil
}
}
}
}
func copyToMedia(media, src string) (string, error) {
data, err := os.ReadFile(src)
if err != nil {
return "", err
}
err = os.MkdirAll(media, 0o755)
if err != nil {
return "", err
}
dest := filepath.Join(media, filepath.Base(src))
return dest, os.WriteFile(dest, data, 0o644)
}
+15
View File
@@ -0,0 +1,15 @@
How agents make real images (photos, illustrations) with generate_image, and how they show inline in the transcript.
# Generating images
`generate_image` makes a raster image from a prompt and saves it as a PNG in the task's media folder (`<worktree>-media/`, outside the worktree, deleted when the task closes). Embed the path it returns in your final message as `![what it shows](<path>)` and the human sees it inline in the transcript; copy it into the worktree when the project itself uses it.
It runs one Codex turn on the Mac byre runs on, under byre's Codex home, and calls Codex's built-in image tool (OpenAI's image model), so:
- It needs Codex signed in there (the `setup` article covers it); until then it fails with `401 Unauthorized`.
- Each image counts against the ChatGPT plan's Codex usage, whichever vendor the calling agent runs on.
- One call takes a minute or two; give it a timeout of at least 3 minutes.
Codex agents (gpt-* models) also have the image tool built in. An image one makes is copied into the media folder and shown inline in the transcript by byre, with no Markdown needed.
Write the prompt as a short spec: subject, style or medium, composition, lighting, and any exact text in quotes. For a change, generate again with the one change stated; it can't edit an earlier image in place.
+1
View File
@@ -885,6 +885,7 @@ func (m *Manager) startLive(ctx context.Context, sess api.AgentSession, task api
return nil, err return nil, err
} }
g += screenshotGuidance g += screenshotGuidance
tools = append(tools, m.generateImageTool(l))
hands, err := m.handsMCP(sess.Id, media) hands, err := m.handsMCP(sess.Id, media)
if err != nil { if err != nil {
return nil, err return nil, err