Files
byre/internal/agents/guidance.go
T

159 lines
6.9 KiB
Go

package agents
import (
"context"
"encoding/json"
"fmt"
"regexp"
"strings"
"unicode/utf8"
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/claude"
"g.fc.run/ai/byre/internal/store"
"g.fc.run/ai/byre/internal/todo"
)
const guidance = `You are running inside byre. The human reads your work in byre's app, not a terminal.
- Ask every question with byre's ask_human tool, never in prose.
- End every turn with a short final message for the human: what you did, anything you departed from in the plan or instructions, risks, and what's next. byre shows it as the turn's summary at the end of the transcript. State every departure from the plan.
- A turn that asks a question has no summary; put everything the human needs in the question's context.
- In that final message and in ask_human context, put long supporting detail (logs, full lists, data) in collapsed sections the human expands on demand: a line <details><summary>Title</summary>, the Markdown content, then a line </details>.
- Don't commit. byre commits everything in the worktree at the end of each turn, using the turn's final message.
- Don't push, don't switch or touch other branches, and don't run git maintenance (gc, prune, worktree prune). byre handles merging and pushing.
- TODO.md at the repo root is the project's list of work for byre. Each item is a top-level ` + "`- `" + ` line holding only a short title, which byre uses as the task's title and the item's identity, with its description in lines indented two spaces under it. ` + "`#`" + ` headings group items into sections, and a checked ` + "`- [x]`" + ` item is skipped.`
const proseQuestionNudge = "Your final message asks the human something in prose. Ask it with byre's ask_human tool " +
"(context with the choices inline as `- ( ) choice` / `- (*) recommended` or `- [ ] choice` / `- [x] recommended` list items, question, why_blocked), " +
"then end your turn with a short summary."
var (
fencedCode = regexp.MustCompile("(?s)```.*?```")
inlineCode = regexp.MustCompile("`[^`\n]*`")
quoted = regexp.MustCompile(`"[^"\n]*"|“[^”\n]*”`)
blockquote = regexp.MustCompile(`(?m)^\s*>.*$`)
tableRow = regexp.MustCompile(`(?m)^\s*\|.*$`)
details = regexp.MustCompile(`(?s)<details>.*?</details>`)
paragraphs = regexp.MustCompile(`\n\s*\n`)
endsQuestion = regexp.MustCompile(`\?[*_)\]]*(\s|$)`)
)
func asksQuestion(text string) bool {
text = fencedCode.ReplaceAllString(text, "")
text = inlineCode.ReplaceAllString(text, "")
text = quoted.ReplaceAllString(text, "")
text = blockquote.ReplaceAllString(text, "")
text = tableRow.ReplaceAllString(text, "")
text = details.ReplaceAllString(text, "")
paras := paragraphs.Split(strings.TrimSpace(text), -1)
return endsQuestion.MatchString(paras[len(paras)-1])
}
func (m *Manager) bounceProseQuestion(l *live) claude.HookFunc {
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
in := struct {
LastAssistantMessage string `json:"last_assistant_message"`
}{}
err := json.Unmarshal(input, &in)
if err != nil {
return nil, err
}
bounce, err := m.takeBounce(ctx, l, in.LastAssistantMessage)
if err != nil || !bounce {
return map[string]any{}, err
}
err = m.emit(ctx, l, "byre", "user.message", map[string]any{"text": proseQuestionNudge, "mid_turn": true, "bounce": true})
if err != nil {
return nil, err
}
return map[string]any{"decision": "block", "reason": proseQuestionNudge}, nil
}
}
func (m *Manager) takeBounce(ctx context.Context, l *live, final string) (bool, error) {
if !asksQuestion(final) {
return false, nil
}
first, err := m.store.FirstPrompt(ctx, l.taskID)
if err != nil {
return false, err
}
if strings.Contains(first, discussPrompt) {
return false, nil
}
open, err := m.store.ListInbox(ctx, store.InboxFilter{TaskID: l.taskID, State: api.Open})
if err != nil {
return false, err
}
if len(open) > 0 {
return false, nil
}
m.mu.Lock()
defer m.mu.Unlock()
if l.asked || l.bounced || l.compacting {
return false, nil
}
l.bounced = true
return true, nil
}
func (m *Manager) taskGuidance(ctx context.Context, task api.Task) (string, error) {
g := guidance + fmt.Sprintf("\n- This task's ports are %d to %d; $PORT holds the first. Run dev servers and anything else that listens on these, so parallel tasks don't collide, and listen on 127.0.0.1. To show the human a running server, call byre's expose_port tool."+
"\n- This worktree starts with only what git tracks. Before other work in a new worktree, do any setup the project's AGENTS.md describes."+
"\n- This task's title is %q. Whoever created the task wrote it, and it may be the only statement of what they want.",
task.PortBase, task.PortBase+store.PortBlock-1, task.Title)
if task.TodoItem != nil {
g += fmt.Sprintf("\n- This task was started from the item %q in %s at the repo root. When the work is done, remove that item from %s in this worktree, so the removal lands in the same merge as the work.",
*task.TodoItem, todo.File, todo.File)
}
if task.ParentTaskId != nil {
parent, err := m.store.GetTask(ctx, *task.ParentTaskId)
if err != nil {
return "", err
}
g += subtaskGuidance(parent)
}
if task.ForkedFrom == nil {
return g, nil
}
src, err := m.store.GetTask(ctx, *task.ForkedFrom)
if err != nil {
return "", err
}
return g + forkGuidance(task, src), nil
}
func longTitleGuidance(task api.Task) string {
n := utf8.RuneCountInString(task.MergeTitle)
if n <= maxMergeTitle {
return ""
}
return fmt.Sprintf("\n- This task's merge commit title, %q, is %d characters, over the %d allowed, so Merge will refuse it. Early in your first turn, call set_merge_title with a one-line title of at most %d characters that describes the work.",
task.MergeTitle, n, maxMergeTitle, maxMergeTitle)
}
func subtaskGuidance(parent api.Task) string {
return fmt.Sprintf("\n- This task is a subtask of byre task %q (%s), whose agent created it and manages it. Your final message at the end of each turn is sent to that agent, which may answer with more instructions. "+
"Your work reaches %s's branch or the default branch when the subtask is merged.",
parent.Title, parent.Id, parent.Id)
}
func forkGuidance(task, src api.Task) string {
return fmt.Sprintf("\n- This conversation was forked from byre task %q at its snapshot %d, into a task of its own: branch %s, worktree %s, which held that snapshot's files when the fork was made. "+
"Everything before the fork happened in %s, which belongs to the other task: from now on read and change files only in %s, never in the other worktree, and use paths under it.",
src.Title, *task.ForkedAt, task.Branch, task.WorktreePath, src.WorktreePath, task.WorktreePath)
}
func remindGuidance(text string) claude.HookFunc {
return func(ctx context.Context, input json.RawMessage) (map[string]any, error) {
return map[string]any{
"hookSpecificOutput": map[string]any{
"hookEventName": "UserPromptSubmit",
"additionalContext": text,
},
}, nil
}
}