Restart conversation support.
The idea here is to let the user restart the conversation, possibly with
a better prompt. This is a common manual workflow, and I'd like to make
it easier.
I hand wrote the agent.go stuff, but Sketch wrote the rest.
Co-Authored-By: sketch <hello@sketch.dev>
diff --git a/loop/agent.go b/loop/agent.go
index 497f048..b698d22 100644
--- a/loop/agent.go
+++ b/loop/agent.go
@@ -92,6 +92,15 @@
OutsideHostname() string
OutsideWorkingDir() string
GitOrigin() string
+
+ // RestartConversation resets the conversation history
+ RestartConversation(ctx context.Context, rev string, initialPrompt string) error
+ // SuggestReprompt suggests a re-prompt based on the current conversation.
+ SuggestReprompt(ctx context.Context) (string, error)
+ // IsInContainer returns true if the agent is running in a container
+ IsInContainer() bool
+ // FirstMessageIndex returns the index of the first message in the current conversation
+ FirstMessageIndex() int
}
type CodingAgentMessageType string
@@ -248,26 +257,29 @@
OverBudget() error
SendMessage(message ant.Message) (*ant.MessageResponse, error)
SendUserTextMessage(s string, otherContents ...ant.Content) (*ant.MessageResponse, error)
+ GetID() string
ToolResultContents(ctx context.Context, resp *ant.MessageResponse) ([]ant.Content, error)
ToolResultCancelContents(resp *ant.MessageResponse) ([]ant.Content, error)
CancelToolUse(toolUseID string, cause error) error
+ SubConvoWithHistory() *ant.Convo
}
type Agent struct {
- convo ConvoInterface
- config AgentConfig // config for this agent
- workingDir string
- repoRoot string // workingDir may be a subdir of repoRoot
- url string
- lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
- initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
- gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
- ready chan struct{} // closed when the agent is initialized (only when under docker)
- startedAt time.Time
- originalBudget ant.Budget
- title string
- branchName string
- codereview *claudetool.CodeReviewer
+ convo ConvoInterface
+ config AgentConfig // config for this agent
+ workingDir string
+ repoRoot string // workingDir may be a subdir of repoRoot
+ url string
+ firstMessageIndex int // index of the first message in the current conversation
+ lastHEAD string // hash of the last HEAD that was pushed to the host (only when under docker)
+ initialCommit string // hash of the Git HEAD when the agent was instantiated or Init()
+ gitRemoteAddr string // HTTP URL of the host git repo (only when under docker)
+ ready chan struct{} // closed when the agent is initialized (only when under docker)
+ startedAt time.Time
+ originalBudget ant.Budget
+ title string
+ branchName string
+ codereview *claudetool.CodeReviewer
// Outside information
outsideHostname string
outsideOS string
@@ -379,6 +391,16 @@
return a.gitOrigin
}
+func (a *Agent) IsInContainer() bool {
+ return a.config.InDocker
+}
+
+func (a *Agent) FirstMessageIndex() int {
+ a.mu.Lock()
+ defer a.mu.Unlock()
+ return a.firstMessageIndex
+}
+
// SetTitleBranch sets the title and branch name of the conversation.
func (a *Agent) SetTitleBranch(title, branchName string) {
a.mu.Lock()
@@ -531,6 +553,7 @@
SessionID string
ClientGOOS string
ClientGOARCH string
+ InDocker bool
UseAnthropicEdit bool
// Outside information
OutsideHostname string
@@ -1382,3 +1405,82 @@
}
return strings.TrimSpace(string(out))
}
+
+func (a *Agent) initGitRevision(ctx context.Context, workingDir, revision string) error {
+ cmd := exec.CommandContext(ctx, "git", "stash")
+ cmd.Dir = workingDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("git stash: %s: %v", out, err)
+ }
+ cmd = exec.CommandContext(ctx, "git", "fetch", "sketch-host")
+ cmd.Dir = workingDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("git fetch: %s: %w", out, err)
+ }
+ cmd = exec.CommandContext(ctx, "git", "checkout", "-f", revision)
+ cmd.Dir = workingDir
+ if out, err := cmd.CombinedOutput(); err != nil {
+ return fmt.Errorf("git checkout %s: %s: %w", revision, out, err)
+ }
+ a.lastHEAD = revision
+ a.initialCommit = revision
+ return nil
+}
+
+func (a *Agent) RestartConversation(ctx context.Context, rev string, initialPrompt string) error {
+ a.mu.Lock()
+ a.title = ""
+ a.firstMessageIndex = len(a.history)
+ a.convo = a.initConvo()
+ gitReset := func() error {
+ if a.config.InDocker && rev != "" {
+ err := a.initGitRevision(ctx, a.workingDir, rev)
+ if err != nil {
+ return err
+ }
+ } else if !a.config.InDocker && rev != "" {
+ return fmt.Errorf("Not resetting git repo when working outside of a container.")
+ }
+ return nil
+ }
+ err := gitReset()
+ a.mu.Unlock()
+ if err != nil {
+ a.pushToOutbox(a.config.Context, errorMessage(err))
+ }
+
+ a.pushToOutbox(a.config.Context, AgentMessage{
+ Type: AgentMessageType, Content: "Conversation restarted.",
+ })
+ if initialPrompt != "" {
+ a.UserMessage(ctx, initialPrompt)
+ }
+ return nil
+}
+
+func (a *Agent) SuggestReprompt(ctx context.Context) (string, error) {
+ msg := `The user has requested a suggestion for a re-prompt.
+
+ Given the current conversation thus far, suggest a re-prompt that would
+ capture the instructions and feedback so far, as well as any
+ research or other information that would be helpful in implementing
+ the task.
+
+ Reply with ONLY the reprompt text.
+ `
+ userMessage := ant.Message{
+ Role: "user",
+ Content: []ant.Content{{Type: "text", Text: msg}},
+ }
+ // By doing this in a subconversation, the agent doesn't call tools (because
+ // there aren't any), and there's not a concurrency risk with on-going other
+ // outstanding conversations.
+ convo := a.convo.SubConvoWithHistory()
+ resp, err := convo.SendMessage(userMessage)
+ if err != nil {
+ a.pushToOutbox(ctx, errorMessage(err))
+ return "", err
+ }
+ textContent := collectTextContent(resp)
+ return textContent, nil
+}
diff --git a/loop/agent_test.go b/loop/agent_test.go
index 61b057c..bde0d20 100644
--- a/loop/agent_test.go
+++ b/loop/agent_test.go
@@ -267,6 +267,8 @@
cumulativeUsageFunc func() ant.CumulativeUsage
resetBudgetFunc func(ant.Budget)
overBudgetFunc func() error
+ getIDFunc func() string
+ subConvoWithHistoryFunc func() *ant.Convo
}
func (m *MockConvoInterface) SendMessage(message ant.Message) (*ant.MessageResponse, error) {
@@ -324,6 +326,20 @@
return nil
}
+func (m *MockConvoInterface) GetID() string {
+ if m.getIDFunc != nil {
+ return m.getIDFunc()
+ }
+ return "mock-convo-id"
+}
+
+func (m *MockConvoInterface) SubConvoWithHistory() *ant.Convo {
+ if m.subConvoWithHistoryFunc != nil {
+ return m.subConvoWithHistoryFunc()
+ }
+ return nil
+}
+
// TestAgentProcessTurnWithNilResponseNilError tests the scenario where Agent.processTurn receives
// a nil value for initialResp and nil error from processUserMessage.
// This test verifies that the implementation properly handles this edge case.
diff --git a/loop/mocks.go b/loop/mocks.go
index 264c6bc..7e05070 100644
--- a/loop/mocks.go
+++ b/loop/mocks.go
@@ -192,6 +192,16 @@
return nil
}
+func (m *MockConvo) GetID() string {
+ m.recordCall("GetID")
+ return "mock-conversation-id"
+}
+
+func (m *MockConvo) SubConvoWithHistory() *ant.Convo {
+ m.recordCall("SubConvoWithHistory")
+ return nil
+}
+
func (m *MockConvo) ResetBudget(_ ant.Budget) {
m.recordCall("ResetBudget")
}
diff --git a/loop/server/loophttp.go b/loop/server/loophttp.go
index c1254e2..c19f806 100644
--- a/loop/server/loophttp.go
+++ b/loop/server/loophttp.go
@@ -64,6 +64,8 @@
SessionID string `json:"session_id"`
SSHAvailable bool `json:"ssh_available"`
SSHError string `json:"ssh_error,omitempty"`
+ InContainer bool `json:"in_container"`
+ FirstMessageIndex int `json:"first_message_index"`
OutsideHostname string `json:"outside_hostname,omitempty"`
InsideHostname string `json:"inside_hostname,omitempty"`
@@ -388,6 +390,8 @@
SessionID: agent.SessionID(),
SSHAvailable: s.sshAvailable,
SSHError: s.sshError,
+ InContainer: agent.IsInContainer(),
+ FirstMessageIndex: agent.FirstMessageIndex(),
}
// Create a JSON encoder with indentation for pretty-printing
@@ -451,6 +455,94 @@
w.Write(data)
})
+ // Handler for POST /restart - restarts the conversation
+ s.mux.HandleFunc("/restart", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodPost {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Parse the request body
+ var requestBody struct {
+ Revision string `json:"revision"`
+ InitialPrompt string `json:"initial_prompt"`
+ }
+
+ decoder := json.NewDecoder(r.Body)
+ if err := decoder.Decode(&requestBody); err != nil {
+ http.Error(w, "Invalid request body: "+err.Error(), http.StatusBadRequest)
+ return
+ }
+ defer r.Body.Close()
+
+ // Call the restart method
+ err := agent.RestartConversation(r.Context(), requestBody.Revision, requestBody.InitialPrompt)
+ if err != nil {
+ http.Error(w, "Failed to restart conversation: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Return success response
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"status": "restarted"})
+ })
+
+ // Handler for /suggest-reprompt - suggests a reprompt based on conversation history
+ // Handler for /commit-description - returns the description of a git commit
+ s.mux.HandleFunc("/commit-description", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Get the revision parameter
+ revision := r.URL.Query().Get("revision")
+ if revision == "" {
+ http.Error(w, "Missing revision parameter", http.StatusBadRequest)
+ return
+ }
+
+ // Run git command to get commit description
+ cmd := exec.Command("git", "log", "--oneline", "--decorate", "-n", "1", revision)
+ // Use the working directory from the agent
+ cmd.Dir = s.agent.WorkingDir()
+
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ http.Error(w, "Failed to get commit description: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Prepare the response
+ resp := map[string]string{
+ "description": strings.TrimSpace(string(output)),
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(resp); err != nil {
+ slog.ErrorContext(r.Context(), "Error encoding commit description response", slog.Any("err", err))
+ }
+ })
+
+ // Handler for /suggest-reprompt - suggests a reprompt based on conversation history
+ s.mux.HandleFunc("/suggest-reprompt", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ // Call the suggest reprompt method
+ suggestedPrompt, err := agent.SuggestReprompt(r.Context())
+ if err != nil {
+ http.Error(w, "Failed to suggest reprompt: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ // Return success response
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(map[string]string{"prompt": suggestedPrompt})
+ })
+
// Handler for POST /chat
s.mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -774,9 +866,11 @@
mux := http.NewServeMux()
mux.HandleFunc("GET /debug/{$}", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ // TODO: pid is not as useful as "outside pid"
fmt.Fprintf(w, `<!doctype html>
<html><head><title>sketch debug</title></head><body>
<h1>sketch debug</h1>
+ pid %d
<ul>
<li><a href="/debug/pprof/cmdline">pprof/cmdline</a></li>
<li><a href="/debug/pprof/profile">pprof/profile</a></li>
@@ -787,7 +881,7 @@
</ul>
</body>
</html>
- `)
+ `, os.Getpid())
})
mux.HandleFunc("GET /debug/pprof/", pprof.Index)
mux.HandleFunc("GET /debug/pprof/cmdline", pprof.Cmdline)