Files

221 lines
6.9 KiB
Go
Raw Permalink Normal View History

package agents
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"strings"
"sync/atomic"
"time"
2026-10-18 15:32:07 -07:00
"g.fc.run/ai/byre/internal/api"
"g.fc.run/ai/byre/internal/mcp"
"g.fc.run/ai/byre/internal/terminal"
)
const (
typeQuiet = 500 * time.Millisecond
typeMaxWait = 10 * time.Second
typeKeep = 256 << 10
)
var agentTerminalSize = api.TerminalSize{Cols: 120, Rows: 40}
func (m *Manager) terminalReadTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "terminal_read",
Description: "Read your task's terminal: the shell in this task's worktree that the human uses from the Terminal tab, and that terminal_type types into. " +
"Returns its recent output as plain text, escape sequences removed, at most the last 64 KiB. " +
"Full-screen programs (vim, top, less) come out garbled, since this is the output stream, not a picture of the screen.",
InputSchema: json.RawMessage(`{"type": "object", "properties": {}}`),
Handler: func(ctx context.Context, args json.RawMessage) (string, error) {
term := m.terminals.Get(l.taskID)
if term == nil {
return "No shell is running in this task's terminal.", nil
}
out := terminal.Text(term.Backlog())
exit, exited := term.ExitStatus()
if exited {
out += "\n[shell ended: " + exit + "]"
}
return out, nil
},
}
}
type terminalTypeArgs struct {
Text string `json:"text"`
}
func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
return mcp.Tool{
Name: "terminal_type",
2026-10-18 16:26:02 -07:00
Description: "Type into your task's terminal: the shell in this task's worktree that the human uses from the Terminal tab, starting it when none is running and waiting for its prompt before typing. " +
"text goes to the shell exactly as keystrokes, so end a command with \"\\n\" to run it, and send control characters for keys (\"\\u0003\" is Ctrl-C). " +
"The human sees everything you type and its output. " +
"To hand the human a command to run (one needing their password, say), type it without the newline and tell them to press Enter in the Terminal tab. In a zsh terminal, the reply then says byre will message you when the shell next returns to its prompt, with the exit status and output, starting a turn if you are idle, so end your turn rather than asking whether they ran it. " +
"Returns the output that followed, as terminal_read does, once the terminal has been quiet for half a second or after 10 seconds; read the rest of a longer command's output with terminal_read.",
InputSchema: json.RawMessage(`{
"type": "object",
"properties": {
"text": {"type": "string", "description": "The keystrokes to send."}
},
"required": ["text"]
}`),
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
args := terminalTypeArgs{}
err := json.Unmarshal(raw, &args)
if err != nil {
return "", fmt.Errorf("malformed terminal_type call: %w", err)
}
task, err := m.store.GetTask(ctx, l.taskID)
if err != nil {
return "", err
}
if task.Status != api.TaskStatusActive {
return "", fmt.Errorf("task %s is %s", task.Id, task.Status)
}
term, err := m.terminals.Attach(task, agentTerminalSize)
if err != nil {
return "", err
}
if args.Text == "" || strings.HasSuffix(args.Text, "\n") || strings.HasSuffix(args.Text, "\r") {
return typeAndWait(ctx, term, args.Text, nil)
}
if !term.Prompts {
out, err := typeAndWait(ctx, term, args.Text, nil)
return out + "\n[this shell isn't zsh, so byre can't tell when the line is run: ask the human to tell you once they have run it]", err
}
out, err := typeAndWait(ctx, term, args.Text, m.wakeOnPrompt(l))
return out + "\n[byre messages you when the shell is next back at its prompt]", err
},
}
}
func (m *Manager) wakeOnPrompt(l *live) func(string, []byte) {
return func(exit string, out []byte) {
text := fmt.Sprintf("The line you left unsent in the terminal was run, and the shell is back at its prompt with exit status %s (130 means it was cancelled with Ctrl-C). The terminal's output since you typed it:\n\n%s", exit, terminal.Text(out))
_, _, err := m.Message(context.Background(), l.id, "byre", text, "")
if err != nil {
slog.Error("wake agent on terminal prompt", "session", l.id, "task", l.taskID, "err", err)
return
}
slog.Info("woke agent on terminal prompt", "session", l.id, "task", l.taskID, "exit", exit)
}
}
func typeAndWait(ctx context.Context, term *terminal.Terminal, text string, wake func(string, []byte)) (string, error) {
term.CancelWake()
2026-10-18 16:26:02 -07:00
backlog, out := term.Subscribe()
if out == nil {
exit, _ := term.ExitStatus()
return "", fmt.Errorf("the shell ended (%s); type again to start a new one", exit)
}
defer term.Unsubscribe(out)
2026-10-18 16:26:02 -07:00
got := []byte{}
if promptWait(term) > 0 {
var err error
got, err = awaitPrompt(ctx, term, out, keepTail(backlog))
if err != nil {
return "", err
}
}
stop := atomic.Bool{}
defer stop.Store(true)
typed := atomic.Int64{}
written := make(chan error, 1)
go func() {
// One byte per write: a program that stops reading blocks a write, and the PTY can't be given a deadline, so this keeps what arrives after we give up to one byte.
for i := 0; i < len(text) && !stop.Load(); i++ {
err := term.Write([]byte{text[i]})
if err != nil {
written <- err
return
}
typed.Add(1)
}
if wake != nil && typed.Load() == int64(len(text)) {
term.WakeOnPrompt(wake)
}
written <- nil
}()
result := func() string {
res := terminal.Text(got)
exit, exited := term.ExitStatus()
if exited {
res += "\n[shell ended: " + exit + "]"
}
if n := typed.Load(); n < int64(len(text)) {
res += fmt.Sprintf("\n[only %d of %d bytes were typed: the terminal stopped reading input]", n, len(text))
}
return res
}
deadline := time.After(typeMaxWait)
quiet := time.NewTimer(typeQuiet)
defer quiet.Stop()
for {
select {
case <-ctx.Done():
return "", ctx.Err()
case err := <-written:
if err != nil {
return "", err
}
written = nil
case <-deadline:
return result(), nil
case <-quiet.C:
return result(), nil
case b, open := <-out:
if !open {
return result(), nil
}
2026-10-18 16:26:02 -07:00
got = keepTail(append(got, b...))
quiet.Reset(typeQuiet)
}
}
}
2026-10-18 16:26:02 -07:00
func keepTail(b []byte) []byte {
if len(b) > 2*typeKeep {
return bytes.Clone(b[len(b)-typeKeep:])
}
return b
}
func promptWait(term *terminal.Terminal) time.Duration {
started, last := term.Activity()
wait := time.Until(started.Add(typeMaxWait))
if !last.IsZero() {
wait = min(wait, time.Until(last.Add(typeQuiet)))
}
return wait
}
func awaitPrompt(ctx context.Context, term *terminal.Terminal, out chan []byte, got []byte) ([]byte, error) {
for {
wait := promptWait(term)
if wait <= 0 {
return got, nil
}
timer := time.NewTimer(wait)
select {
case <-ctx.Done():
timer.Stop()
return nil, ctx.Err()
case <-timer.C:
case b, open := <-out:
timer.Stop()
if !open {
return got, nil
}
got = keepTail(append(got, b...))
}
}
}