Add multiple terminals per task and saved commands that start one
This commit is contained in:
+60
-65
@@ -1,7 +1,6 @@
|
||||
package agents
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
@@ -15,23 +14,34 @@ import (
|
||||
"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}
|
||||
|
||||
const terminalArg = `"terminal": {"type": "string", "description": "The id of one of the task's terminals, as the list at the end of each reply gives it. Without it, the first."}`
|
||||
|
||||
type terminalReadArgs struct {
|
||||
Terminal string `json:"terminal"`
|
||||
}
|
||||
|
||||
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. " +
|
||||
Description: "Read one of your task's terminals: the shells 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, then the task's terminals by id and name. " +
|
||||
"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)
|
||||
InputSchema: json.RawMessage(`{"type": "object", "properties": {` + terminalArg + `}}`),
|
||||
Handler: func(ctx context.Context, raw json.RawMessage) (string, error) {
|
||||
args := terminalReadArgs{}
|
||||
err := json.Unmarshal(raw, &args)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("malformed terminal_read call: %w", err)
|
||||
}
|
||||
term := m.terminals.First(l.taskID)
|
||||
if args.Terminal != "" {
|
||||
term = m.terminals.Get(l.taskID, args.Terminal)
|
||||
if term == nil {
|
||||
return "", fmt.Errorf("no terminal %s in this task%s", args.Terminal, m.terminalList(l.taskID))
|
||||
}
|
||||
}
|
||||
if term == nil {
|
||||
return "No shell is running in this task's terminal.", nil
|
||||
}
|
||||
@@ -40,11 +50,23 @@ func (m *Manager) terminalReadTool(l *live) mcp.Tool {
|
||||
if exited {
|
||||
out += "\n[shell ended: " + exit + "]"
|
||||
}
|
||||
return out + inputLine(term), nil
|
||||
return out + inputLine(term) + m.terminalList(l.taskID), nil
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) terminalList(taskID string) string {
|
||||
terms := m.terminals.List(taskID)
|
||||
if len(terms) == 0 {
|
||||
return ""
|
||||
}
|
||||
out := "\n[terminals:"
|
||||
for _, t := range terms {
|
||||
out += fmt.Sprintf(" %s %q", t.ID, t.Name)
|
||||
}
|
||||
return out + "]"
|
||||
}
|
||||
|
||||
func inputLine(term *terminal.Terminal) string {
|
||||
line, ok := term.InputLine()
|
||||
if !ok {
|
||||
@@ -74,15 +96,16 @@ func init() {
|
||||
}
|
||||
|
||||
type terminalTypeArgs struct {
|
||||
Keys []string `json:"keys"`
|
||||
Text string `json:"text"`
|
||||
Append bool `json:"append"`
|
||||
Terminal string `json:"terminal"`
|
||||
Keys []string `json:"keys"`
|
||||
Text string `json:"text"`
|
||||
Append bool `json:"append"`
|
||||
}
|
||||
|
||||
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 and waiting for its prompt before typing. " +
|
||||
Description: "Type into one of your task's terminals: the shells in this task's worktree that the human uses from the Terminal tab, by default the first, starting a shell when none is running and waiting for its prompt before typing. " +
|
||||
"keys are named keys sent first (ctrl-c cancels, ctrl-u clears the input line, enter runs it), then text goes to the shell exactly as keystrokes, so end a command with a real line break to run it (an escape you write in text, such as \"\\u0003\", can arrive as those literal characters, so send control keys through keys), or press enter in a later call. " +
|
||||
"When the input line still holds unsent text after keys, text is refused, so that it isn't joined onto someone else's command: clear it with ctrl-u first, or set append to add to it on purpose. " +
|
||||
"Replies from this tool and terminal_read end with the shell's current input line when zsh is at its prompt. " +
|
||||
@@ -92,6 +115,7 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
||||
InputSchema: json.RawMessage(`{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
` + terminalArg + `,
|
||||
"keys": {"type": "array", "items": {"type": "string", "enum": ["ctrl-a", "ctrl-b", "ctrl-c", "ctrl-d", "ctrl-e", "ctrl-f", "ctrl-g", "ctrl-h", "ctrl-i", "ctrl-j", "ctrl-k", "ctrl-l", "ctrl-m", "ctrl-n", "ctrl-o", "ctrl-p", "ctrl-q", "ctrl-r", "ctrl-s", "ctrl-t", "ctrl-u", "ctrl-v", "ctrl-w", "ctrl-x", "ctrl-y", "ctrl-z", "enter", "escape", "tab", "backspace", "up", "down", "left", "right"]}, "description": "Named keys to press, in order, before text."},
|
||||
"text": {"type": "string", "description": "The keystrokes to send after keys."},
|
||||
"append": {"type": "boolean", "description": "Type text even though the input line already holds unsent text, adding to it."}
|
||||
@@ -110,9 +134,18 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
||||
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
|
||||
term := m.terminals.First(l.taskID)
|
||||
if args.Terminal != "" {
|
||||
term = m.terminals.Get(l.taskID, args.Terminal)
|
||||
if term == nil {
|
||||
return "", fmt.Errorf("no terminal %s in this task%s", args.Terminal, m.terminalList(l.taskID))
|
||||
}
|
||||
}
|
||||
if term == nil {
|
||||
term, err = m.terminals.Start(task, "Shell", "", agentTerminalSize)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
keys := ""
|
||||
for _, k := range args.Keys {
|
||||
@@ -144,7 +177,7 @@ func (m *Manager) terminalTypeTool(l *live) mcp.Tool {
|
||||
sent += args.Text
|
||||
}
|
||||
}
|
||||
out += inputLine(term)
|
||||
out += inputLine(term) + m.terminalList(l.taskID)
|
||||
submitted := strings.HasSuffix(sent, "\n") || strings.HasSuffix(sent, "\r")
|
||||
if !term.Prompts {
|
||||
if args.Text != "" {
|
||||
@@ -184,9 +217,9 @@ func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (str
|
||||
defer term.Unsubscribe(out)
|
||||
|
||||
got := []byte{}
|
||||
if promptWait(term) > 0 {
|
||||
if term.Settling() {
|
||||
var err error
|
||||
got, err = awaitPrompt(ctx, term, out, keepTail(backlog))
|
||||
got, err = term.AwaitPrompt(ctx, out, terminal.KeepTail(backlog))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
@@ -220,8 +253,8 @@ func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (str
|
||||
}
|
||||
return res
|
||||
}
|
||||
deadline := time.After(typeMaxWait)
|
||||
quiet := time.NewTimer(typeQuiet)
|
||||
deadline := time.After(terminal.MaxWait)
|
||||
quiet := time.NewTimer(terminal.Quiet)
|
||||
defer quiet.Stop()
|
||||
for {
|
||||
select {
|
||||
@@ -240,46 +273,8 @@ func typeAndWait(ctx context.Context, term *terminal.Terminal, text string) (str
|
||||
if !open {
|
||||
return result(), nil
|
||||
}
|
||||
got = keepTail(append(got, b...))
|
||||
quiet.Reset(typeQuiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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...))
|
||||
got = terminal.KeepTail(append(got, b...))
|
||||
quiet.Reset(terminal.Quiet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user