feat: add bidirectional comment system and agent input request flow
- Add Comment model and CRUD operations (repository, service layers) - Add WAITING_FOR_INPUT task state for agent input requests - Add WebSocket actions for comment.add, comment.get, comment.list - Add ActionCommentAdded and ActionInputRequested notifications - Update PromptAgent to return PromptResult with stop reason - Add e2e test for agent input request flow - Add PR template for the repository This enables agents to request input from users by returning stopReason 'needs_input' which transitions the task to WAITING_FOR_INPUT state and creates an agent comment.
This commit is contained in:
@@ -323,7 +323,7 @@ func (m *Manager) initializeACPSession(ctx context.Context, instance *AgentInsta
|
||||
promptCtx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
err := instance.agentctl.Prompt(promptCtx, taskDescription)
|
||||
resp, err := instance.agentctl.Prompt(promptCtx, taskDescription)
|
||||
if err != nil {
|
||||
m.logger.Error("ACP prompt failed",
|
||||
zap.String("instance_id", instance.ID),
|
||||
@@ -331,13 +331,29 @@ func (m *Manager) initializeACPSession(ctx context.Context, instance *AgentInsta
|
||||
return fmt.Errorf("prompt failed: %w", err)
|
||||
}
|
||||
|
||||
// Prompt completed - mark agent as READY for follow-up prompts
|
||||
m.logger.Info("ACP prompt completed, agent ready for follow-up prompts",
|
||||
zap.String("instance_id", instance.ID))
|
||||
if err := m.MarkReady(instance.ID); err != nil {
|
||||
m.logger.Error("failed to mark instance as ready",
|
||||
// Check if agent needs input - if so, publish input_required event
|
||||
stopReason := ""
|
||||
if resp != nil {
|
||||
stopReason = string(resp.StopReason)
|
||||
}
|
||||
m.logger.Info("ACP prompt completed",
|
||||
zap.String("instance_id", instance.ID),
|
||||
zap.String("stop_reason", stopReason))
|
||||
|
||||
if resp != nil && resp.StopReason == "needs_input" {
|
||||
m.logger.Info("agent requesting user input on initial prompt",
|
||||
zap.String("instance_id", instance.ID),
|
||||
zap.Error(err))
|
||||
zap.String("task_id", instance.TaskID))
|
||||
m.publishInputRequiredFromPrompt(instance, "The agent needs more information to proceed.")
|
||||
} else {
|
||||
// Prompt completed - mark agent as READY for follow-up prompts
|
||||
m.logger.Info("agent ready for follow-up prompts",
|
||||
zap.String("instance_id", instance.ID))
|
||||
if err := m.MarkReady(instance.ID); err != nil {
|
||||
m.logger.Error("failed to mark instance as ready",
|
||||
zap.String("instance_id", instance.ID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
m.logger.Warn("no task description provided, marking as ready",
|
||||
@@ -449,6 +465,39 @@ func (m *Manager) publishSessionNotification(instance *AgentInstance, notificati
|
||||
}
|
||||
}
|
||||
|
||||
// publishInputRequiredFromPrompt publishes an input_required event when the agent needs user input
|
||||
// This is called after a prompt completes with stopReason="needs_input"
|
||||
func (m *Manager) publishInputRequiredFromPrompt(instance *AgentInstance, message string) {
|
||||
if m.eventBus == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Structure must match protocol.Message with type "input_required"
|
||||
data := map[string]interface{}{
|
||||
"type": "input_required",
|
||||
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
|
||||
"agent_id": instance.ID,
|
||||
"task_id": instance.TaskID,
|
||||
"data": map[string]interface{}{
|
||||
"message": message,
|
||||
},
|
||||
}
|
||||
|
||||
event := bus.NewEvent(events.ACPMessage, "agent-manager", data)
|
||||
subject := events.BuildACPSubject(instance.TaskID)
|
||||
|
||||
if err := m.eventBus.Publish(context.Background(), subject, event); err != nil {
|
||||
m.logger.Error("failed to publish input_required event",
|
||||
zap.String("instance_id", instance.ID),
|
||||
zap.String("task_id", instance.TaskID),
|
||||
zap.Error(err))
|
||||
} else {
|
||||
m.logger.Info("published input_required event",
|
||||
zap.String("task_id", instance.TaskID),
|
||||
zap.String("message", message))
|
||||
}
|
||||
}
|
||||
|
||||
// publishSessionInfo publishes a session_info event with the session ID
|
||||
// This allows the orchestrator to store the session ID in task metadata for resumption
|
||||
func (m *Manager) publishSessionInfo(instance *AgentInstance, sessionID string) {
|
||||
@@ -633,23 +682,30 @@ func (m *Manager) expandMountTemplate(source, workspacePath, taskID string) stri
|
||||
}
|
||||
|
||||
// PromptAgent sends a follow-up prompt to a running agent
|
||||
func (m *Manager) PromptAgent(ctx context.Context, instanceID string, prompt string) error {
|
||||
// PromptResult contains the result of a prompt operation
|
||||
type PromptResult struct {
|
||||
StopReason string // The reason the agent stopped (e.g., "end_turn", "needs_input")
|
||||
NeedsInput bool // True if the agent is requesting user input
|
||||
}
|
||||
|
||||
// PromptAgent sends a follow-up prompt to a running agent
|
||||
func (m *Manager) PromptAgent(ctx context.Context, instanceID string, prompt string) (*PromptResult, error) {
|
||||
m.mu.Lock()
|
||||
instance, exists := m.instances[instanceID]
|
||||
if !exists {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("instance %q not found", instanceID)
|
||||
return nil, fmt.Errorf("instance %q not found", instanceID)
|
||||
}
|
||||
|
||||
if instance.agentctl == nil {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("instance %q has no agentctl client", instanceID)
|
||||
return nil, fmt.Errorf("instance %q has no agentctl client", instanceID)
|
||||
}
|
||||
|
||||
// Accept both RUNNING (initial) and READY (after first prompt) states
|
||||
if instance.Status != v1.AgentStatusRunning && instance.Status != v1.AgentStatusReady {
|
||||
m.mu.Unlock()
|
||||
return fmt.Errorf("instance %q is not ready for prompts (status: %s)", instanceID, instance.Status)
|
||||
return nil, fmt.Errorf("instance %q is not ready for prompts (status: %s)", instanceID, instance.Status)
|
||||
}
|
||||
|
||||
// Set status to RUNNING while processing
|
||||
@@ -661,18 +717,32 @@ func (m *Manager) PromptAgent(ctx context.Context, instanceID string, prompt str
|
||||
zap.Int("prompt_length", len(prompt)))
|
||||
|
||||
// Prompt is synchronous - blocks until agent completes
|
||||
if err := instance.agentctl.Prompt(ctx, prompt); err != nil {
|
||||
return err
|
||||
resp, err := instance.agentctl.Prompt(ctx, prompt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Prompt completed - mark as READY for next prompt
|
||||
if err := m.MarkReady(instanceID); err != nil {
|
||||
m.logger.Error("failed to mark instance as ready after prompt",
|
||||
result := &PromptResult{
|
||||
StopReason: string(resp.StopReason),
|
||||
NeedsInput: resp.StopReason == "needs_input",
|
||||
}
|
||||
|
||||
// Check if agent needs input - if so, publish input_required event
|
||||
if result.NeedsInput {
|
||||
m.logger.Info("agent requesting user input",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.Error(err))
|
||||
zap.String("task_id", instance.TaskID))
|
||||
m.publishInputRequiredFromPrompt(instance, "The agent needs more information to proceed.")
|
||||
} else {
|
||||
// Prompt completed normally - mark as READY for next prompt
|
||||
if err := m.MarkReady(instanceID); err != nil {
|
||||
m.logger.Error("failed to mark instance as ready after prompt",
|
||||
zap.String("instance_id", instanceID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// StopAgent stops an agent instance
|
||||
|
||||
Reference in New Issue
Block a user