151 lines
4.3 KiB
Go
151 lines
4.3 KiB
Go
package agents
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"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",
|
|
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. " +
|
|
"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. " +
|
|
"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
|
|
}
|
|
return typeAndWait(ctx, term, args.Text)
|
|
},
|
|
}
|
|
}
|
|
|
|
func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (string, error) {
|
|
_, 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)
|
|
|
|
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)
|
|
}
|
|
written <- nil
|
|
}()
|
|
|
|
got := []byte{}
|
|
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
|
|
}
|
|
got = append(got, b...)
|
|
if len(got) > 2*typeKeep {
|
|
got = bytes.Clone(got[len(got)-typeKeep:])
|
|
}
|
|
quiet.Reset(typeQuiet)
|
|
}
|
|
}
|
|
}
|