xworkmate-bridge/internal/acp/multi_agent.go
2026-05-18 16:57:17 +08:00

610 lines
20 KiB
Go

package acp
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
"xworkmate-bridge/internal/shared"
)
const (
multiAgentTargetID = "multi-agent"
defaultMultiAgentTimeout = 5 * time.Minute
)
type multiAgentPlan struct {
Mode string
Steps []multiAgentStep
MaxTurns int
StopConditions []string
SharedWorkspace string
}
type multiAgentStep struct {
Index int
ProviderID string
Prompt string
OutputAs string
Timeout time.Duration
}
type multiAgentStepResult struct {
Index int
ProviderID string
Status string
Output string
Error string
DurationMs int64
Result map[string]any
}
func isMultiAgentSessionRequest(params map[string]any) bool {
if parseBool(params["multiAgent"]) {
return true
}
if strings.EqualFold(strings.TrimSpace(shared.StringArg(params, "mode", "")), multiAgentTargetID) {
return true
}
routing := shared.AsMap(params["routing"])
return strings.TrimSpace(shared.StringArg(routing, "orchestrationMode", "")) != ""
}
func (o *SessionOrchestrator) ProcessMultiAgent(ctx context.Context, method string, params map[string]any, notify func(map[string]any)) (map[string]any, *shared.RPCError) {
if method != "session.start" && method != "session.message" {
return nil, &shared.RPCError{Code: -32601, Message: "MULTI_AGENT_METHOD_NOT_ALLOWED: " + method}
}
plan, rpcErr := o.parseMultiAgentPlan(params)
if rpcErr != nil {
return nil, rpcErr
}
sessionID := shared.StringArg(params, "sessionId", "")
threadID := shared.StringArg(params, "threadId", sessionID)
turnID := fmt.Sprintf("turn-%d", time.Now().UnixNano())
sess := o.server.getOrCreateSession(sessionID, threadID)
sess.mu.Lock()
sess.target = multiAgentTargetID
sess.provider = ""
sess.mode = multiAgentTargetID
sess.control.ControlPlaneSessionID = sessionID
sess.control.ThreadID = threadID
sess.control.RequestedWorkingDir = plan.SharedWorkspace
sess.control.RemoteWorkingDirHint = strings.TrimSpace(shared.StringArg(params, "remoteWorkingDirectoryHint", ""))
sess.control.UpdatedAt = time.Now()
sess.task = QueuedTask{
SessionID: sessionID,
ThreadID: threadID,
TurnID: turnID,
Target: multiAgentTargetID,
State: TaskStateRunning,
Kind: TaskKindMultiAgent,
UpdatedAt: time.Now(),
}
if prompt := strings.TrimSpace(shared.StringArg(params, "taskPrompt", "")); prompt != "" {
sess.history = append(sess.history, "USER: "+prompt)
}
sess.mu.Unlock()
o.server.emitSessionUpdate(notify, turnID, map[string]any{
"type": "status",
"event": "multi_agent_started",
"message": "multi-agent orchestration started",
"mode": plan.Mode,
"stepCount": len(plan.Steps),
"pending": true,
"error": false,
})
result := o.runMultiAgentPlan(ctx, method, params, plan, sessionID, threadID, turnID, notify)
routing := RoutingResult{TargetID: multiAgentTargetID}
normalized := o.normalizeResult(sess, result, routing, turnID, withMultiAgentWorkingDirectory(params, plan.SharedWorkspace))
o.server.emitSessionUpdate(notify, turnID, map[string]any{
"type": "status",
"event": "multi_agent_completed",
"message": strings.TrimSpace(shared.StringArg(normalized, "summary", "multi-agent orchestration completed")),
"pending": false,
"error": !parseBool(normalized["success"]),
"result": normalized,
})
return normalized, nil
}
func (o *SessionOrchestrator) parseMultiAgentPlan(params map[string]any) (multiAgentPlan, *shared.RPCError) {
routing := shared.AsMap(params["routing"])
mode := strings.ToLower(strings.TrimSpace(shared.StringArg(routing, "orchestrationMode", "")))
if mode == "" {
mode = strings.ToLower(strings.TrimSpace(shared.StringArg(params, "orchestrationMode", "")))
}
if mode == "" {
mode = "sequence"
}
switch mode {
case "sequence", "parallel", "race", "conversation":
default:
return multiAgentPlan{}, &shared.RPCError{Code: -32602, Message: "MULTI_AGENT_INVALID_MODE: " + mode}
}
sharedWorkspace := strings.TrimSpace(shared.StringArg(routing, "sharedWorkspace", ""))
if sharedWorkspace == "" {
sharedWorkspace = strings.TrimSpace(shared.StringArg(params, "workingDirectory", ""))
}
if sharedWorkspace == "" {
sharedWorkspace = filepath.Join(os.TempDir(), "xworkmate-bridge", "multi-agent", safeMultiAgentPathSegment(shared.StringArg(params, "sessionId", "session")))
}
if err := os.MkdirAll(sharedWorkspace, 0o755); err != nil {
return multiAgentPlan{}, &shared.RPCError{Code: -32602, Message: "MULTI_AGENT_WORKSPACE_UNAVAILABLE: " + err.Error()}
}
steps := parseMultiAgentSteps(shared.ListArg(routing, "steps"), params, defaultMultiAgentTimeout)
if len(steps) == 0 {
steps = parseMultiAgentSteps(shared.ListArg(params, "steps"), params, defaultMultiAgentTimeout)
}
if mode == "conversation" && len(steps) == 0 {
steps = parseConversationParticipants(shared.ListArg(routing, "participants"), params, defaultMultiAgentTimeout)
}
if len(steps) == 0 {
return multiAgentPlan{}, &shared.RPCError{Code: -32602, Message: "MULTI_AGENT_STEPS_REQUIRED"}
}
for index := range steps {
steps[index].Index = index
if steps[index].ProviderID == "" {
return multiAgentPlan{}, &shared.RPCError{Code: -32602, Message: "MULTI_AGENT_PROVIDER_REQUIRED"}
}
if steps[index].Prompt == "" {
return multiAgentPlan{}, &shared.RPCError{Code: -32602, Message: "MULTI_AGENT_PROMPT_REQUIRED"}
}
}
maxTurns := parsePositiveInt(routing["maxTurns"])
if maxTurns == 0 {
maxTurns = parsePositiveInt(params["maxTurns"])
}
if maxTurns == 0 {
maxTurns = len(steps)
}
if mode == "conversation" && maxTurns < len(steps) {
maxTurns = len(steps)
}
stopConditions := parseMultiAgentStringList(routing["stopConditions"])
if len(stopConditions) == 0 {
stopConditions = []string{"STATUS: DONE", "STATUS: CONSENSUS"}
}
return multiAgentPlan{
Mode: mode,
Steps: steps,
MaxTurns: maxTurns,
StopConditions: stopConditions,
SharedWorkspace: sharedWorkspace,
}, nil
}
func parseMultiAgentSteps(raw []any, params map[string]any, fallbackTimeout time.Duration) []multiAgentStep {
steps := make([]multiAgentStep, 0, len(raw))
defaultPrompt := strings.TrimSpace(shared.StringArg(params, "taskPrompt", ""))
for _, item := range raw {
stepParams := shared.AsMap(item)
if len(stepParams) == 0 {
continue
}
timeout := time.Duration(parsePositiveInt(stepParams["timeoutMs"])) * time.Millisecond
if timeout <= 0 {
timeout = time.Duration(parsePositiveInt(stepParams["timeoutSeconds"])) * time.Second
}
if timeout <= 0 {
timeout = fallbackTimeout
}
prompt := strings.TrimSpace(firstNonEmptyString(stepParams, "prompt", "taskPrompt"))
if prompt == "" {
prompt = defaultPrompt
}
steps = append(steps, multiAgentStep{
ProviderID: strings.TrimSpace(firstNonEmptyString(stepParams, "providerId", "provider", "agent")),
Prompt: prompt,
OutputAs: strings.TrimSpace(shared.StringArg(stepParams, "outputAs", "")),
Timeout: timeout,
})
}
return steps
}
func parseConversationParticipants(raw []any, params map[string]any, fallbackTimeout time.Duration) []multiAgentStep {
steps := make([]multiAgentStep, 0, len(raw))
prompt := strings.TrimSpace(shared.StringArg(params, "taskPrompt", ""))
for _, item := range raw {
providerID := strings.TrimSpace(fmt.Sprint(item))
if mapped := shared.AsMap(item); len(mapped) > 0 {
providerID = strings.TrimSpace(firstNonEmptyString(mapped, "providerId", "provider", "agent"))
}
if providerID == "" || providerID == "<nil>" {
continue
}
steps = append(steps, multiAgentStep{
ProviderID: providerID,
Prompt: prompt,
Timeout: fallbackTimeout,
})
}
return steps
}
func (o *SessionOrchestrator) runMultiAgentPlan(ctx context.Context, method string, params map[string]any, plan multiAgentPlan, sessionID string, threadID string, turnID string, notify func(map[string]any)) map[string]any {
switch plan.Mode {
case "parallel":
return o.runMultiAgentParallel(ctx, method, params, plan, sessionID, threadID, turnID, notify)
case "race":
return o.runMultiAgentRace(ctx, method, params, plan, sessionID, threadID, turnID, notify)
case "conversation":
return o.runMultiAgentConversation(ctx, method, params, plan, sessionID, threadID, turnID, notify)
default:
return o.runMultiAgentSequence(ctx, method, params, plan, sessionID, threadID, turnID, notify)
}
}
func (o *SessionOrchestrator) runMultiAgentSequence(ctx context.Context, method string, params map[string]any, plan multiAgentPlan, sessionID string, threadID string, turnID string, notify func(map[string]any)) map[string]any {
values := map[string]string{"input": strings.TrimSpace(shared.StringArg(params, "taskPrompt", ""))}
results := make([]map[string]any, 0, len(plan.Steps))
var lastOutput string
for _, step := range plan.Steps {
step.Prompt = renderMultiAgentPrompt(step.Prompt, values)
stepResult := o.runMultiAgentProviderStep(ctx, method, params, plan, step, sessionID, threadID, turnID, false, notify)
results = append(results, stepResult.Map())
if stepResult.Status != "completed" {
return multiAgentResult(plan, results, "", stepResult.Error, false)
}
lastOutput = stepResult.Output
values["previousOutput"] = stepResult.Output
if step.OutputAs != "" {
values[step.OutputAs] = stepResult.Output
}
}
return multiAgentResult(plan, results, lastOutput, "", true)
}
func (o *SessionOrchestrator) runMultiAgentParallel(ctx context.Context, method string, params map[string]any, plan multiAgentPlan, sessionID string, threadID string, turnID string, notify func(map[string]any)) map[string]any {
results := make([]multiAgentStepResult, len(plan.Steps))
var wg sync.WaitGroup
for _, step := range plan.Steps {
wg.Add(1)
go func(step multiAgentStep) {
defer wg.Done()
results[step.Index] = o.runMultiAgentProviderStep(ctx, method, params, plan, step, sessionID, threadID, turnID, false, notify)
}(step)
}
wg.Wait()
return multiAgentAggregateResult(plan, results)
}
func (o *SessionOrchestrator) runMultiAgentRace(ctx context.Context, method string, params map[string]any, plan multiAgentPlan, sessionID string, threadID string, turnID string, notify func(map[string]any)) map[string]any {
raceCtx, cancel := context.WithCancel(ctx)
defer cancel()
resultsCh := make(chan multiAgentStepResult, len(plan.Steps))
for _, step := range plan.Steps {
go func(step multiAgentStep) {
resultsCh <- o.runMultiAgentProviderStep(raceCtx, method, params, plan, step, sessionID, threadID, turnID, false, notify)
}(step)
}
results := make([]multiAgentStepResult, 0, len(plan.Steps))
for range plan.Steps {
stepResult := <-resultsCh
results = append(results, stepResult)
if stepResult.Status == "completed" {
cancel()
return multiAgentResult(plan, multiAgentStepResultMaps(results), stepResult.Output, "", true)
}
}
return multiAgentResult(plan, multiAgentStepResultMaps(results), "", "all agents failed", false)
}
func (o *SessionOrchestrator) runMultiAgentConversation(ctx context.Context, method string, params map[string]any, plan multiAgentPlan, sessionID string, threadID string, turnID string, notify func(map[string]any)) map[string]any {
results := make([]map[string]any, 0, plan.MaxTurns)
started := make(map[string]bool)
lastAgent := ""
lastOutput := ""
topic := strings.TrimSpace(shared.StringArg(params, "taskPrompt", ""))
for turn := 0; turn < plan.MaxTurns; turn++ {
step := plan.Steps[turn%len(plan.Steps)]
if lastOutput == "" {
step.Prompt = fmt.Sprintf("Topic:\n%s\n\nShared workspace: %s\n\nRespond as %s.", topic, plan.SharedWorkspace, step.ProviderID)
} else {
step.Prompt = fmt.Sprintf("[%s]: %s\n\nContinue the discussion as %s. Shared workspace: %s", lastAgent, lastOutput, step.ProviderID, plan.SharedWorkspace)
}
useSend := started[step.ProviderID]
stepResult := o.runMultiAgentProviderStep(ctx, method, params, plan, step, sessionID, threadID, turnID, useSend, notify)
started[step.ProviderID] = true
entry := stepResult.Map()
entry["conversationTurn"] = turn + 1
results = append(results, entry)
if stepResult.Status != "completed" {
return multiAgentResult(plan, results, "", stepResult.Error, false)
}
lastAgent = step.ProviderID
lastOutput = stepResult.Output
if multiAgentConversationShouldStop(lastOutput, plan.StopConditions) {
result := multiAgentResult(plan, results, lastOutput, "", true)
result["stopReason"] = "stop_condition"
return result
}
}
result := multiAgentResult(plan, results, lastOutput, "", true)
result["stopReason"] = "max_turns"
return result
}
func (o *SessionOrchestrator) runMultiAgentProviderStep(ctx context.Context, method string, params map[string]any, plan multiAgentPlan, step multiAgentStep, sessionID string, threadID string, turnID string, useSend bool, notify func(map[string]any)) multiAgentStepResult {
startedAt := time.Now()
o.server.emitSessionUpdate(notify, turnID, map[string]any{
"type": "status",
"event": "multi_agent_step_started",
"providerId": step.ProviderID,
"stepIndex": step.Index,
"pending": true,
"error": false,
})
compat, ok := o.server.providers[step.ProviderID]
if !ok {
return failedMultiAgentStep(step, startedAt, "provider unavailable")
}
stepCtx := ctx
cancel := func() {}
if step.Timeout > 0 {
stepCtx, cancel = context.WithTimeout(ctx, step.Timeout)
}
defer cancel()
stepSessionID := fmt.Sprintf("%s/%s/%d/%s", sessionID, turnID, step.Index, step.ProviderID)
stepParams := multiAgentStepParams(params, step, plan.SharedWorkspace)
sink := func(update map[string]any) {
o.server.emitSessionUpdate(notify, turnID, map[string]any{
"type": "delta",
"event": "multi_agent_step_update",
"providerId": step.ProviderID,
"stepIndex": step.Index,
"providerUpdate": update,
"pending": true,
"error": false,
})
}
var result map[string]any
var err error
if useSend {
result, err = compat.SendMessage(stepCtx, stepSessionID, threadID, stepParams, sink)
if _, ok := asSessionContinuationUnavailableError(err); ok {
result, err = compat.StartSession(stepCtx, stepSessionID, threadID, stepParams, sink)
}
} else {
result, err = compat.StartSession(stepCtx, stepSessionID, threadID, stepParams, sink)
}
stepResult := multiAgentStepResult{
Index: step.Index,
ProviderID: step.ProviderID,
Status: "completed",
Output: strings.TrimSpace(multiAgentOutputFromResult(result)),
DurationMs: time.Since(startedAt).Milliseconds(),
Result: result,
}
if err != nil {
stepResult.Status = "failed"
stepResult.Error = err.Error()
} else if result != nil {
if value, ok := result["success"]; ok && !parseBool(value) {
stepResult.Status = "failed"
stepResult.Error = firstNonEmptyString(result, "error", "message", "unavailableMessage")
}
}
if stepResult.Status == "completed" && stepResult.Output == "" {
stepResult.Status = "failed"
stepResult.Error = "provider returned no displayable output"
}
if stepResult.Error == "" && stepResult.Status == "failed" {
stepResult.Error = "provider execution failed"
}
o.server.emitSessionUpdate(notify, turnID, map[string]any{
"type": "status",
"event": "multi_agent_step_completed",
"providerId": step.ProviderID,
"stepIndex": step.Index,
"status": stepResult.Status,
"message": stepResult.Output,
"pending": false,
"error": stepResult.Status != "completed",
})
return stepResult
}
func multiAgentStepParams(params map[string]any, step multiAgentStep, sharedWorkspace string) map[string]any {
next := make(map[string]any, len(params)+2)
for key, value := range params {
next[key] = value
}
delete(next, "multiAgent")
delete(next, "mode")
next["taskPrompt"] = step.Prompt
next["workingDirectory"] = sharedWorkspace
next["routing"] = map[string]any{
"routingMode": "explicit",
"explicitExecutionTarget": "singleAgent",
"explicitProviderId": step.ProviderID,
}
return next
}
func multiAgentOutputFromResult(result map[string]any) string {
if result == nil {
return ""
}
return firstNonEmptyString(result, "output", "summary", "message", "text")
}
func failedMultiAgentStep(step multiAgentStep, startedAt time.Time, message string) multiAgentStepResult {
return multiAgentStepResult{
Index: step.Index,
ProviderID: step.ProviderID,
Status: "failed",
Error: message,
DurationMs: time.Since(startedAt).Milliseconds(),
}
}
func (r multiAgentStepResult) Map() map[string]any {
result := map[string]any{
"index": r.Index,
"providerId": r.ProviderID,
"status": r.Status,
"durationMs": r.DurationMs,
}
if r.Output != "" {
result["output"] = r.Output
}
if r.Error != "" {
result["error"] = r.Error
}
if r.Result != nil {
result["result"] = r.Result
}
return result
}
func multiAgentAggregateResult(plan multiAgentPlan, results []multiAgentStepResult) map[string]any {
mapped := multiAgentStepResultMaps(results)
outputs := make([]string, 0, len(results))
var errorText string
success := true
for _, result := range results {
if result.Status != "completed" {
success = false
if errorText == "" {
errorText = result.ProviderID + ": " + result.Error
}
continue
}
if result.Output != "" {
outputs = append(outputs, result.ProviderID+": "+result.Output)
}
}
return multiAgentResult(plan, mapped, strings.Join(outputs, "\n"), errorText, success)
}
func multiAgentStepResultMaps(results []multiAgentStepResult) []map[string]any {
mapped := make([]map[string]any, 0, len(results))
for _, result := range results {
mapped = append(mapped, result.Map())
}
return mapped
}
func multiAgentResult(plan multiAgentPlan, steps []map[string]any, output string, errorText string, success bool) map[string]any {
status := "completed"
if !success {
status = "failed"
}
if output == "" && errorText != "" {
output = errorText
}
result := map[string]any{
"success": success,
"status": status,
"mode": multiAgentTargetID,
"orchestrationMode": plan.Mode,
"resolvedExecutionTarget": multiAgentTargetID,
"resolvedProviderId": "",
"resolvedGatewayProviderId": "",
"steps": steps,
"sharedWorkspace": plan.SharedWorkspace,
}
if output != "" {
result["output"] = output
result["summary"] = output
result["message"] = output
}
if errorText != "" {
result["error"] = errorText
}
return result
}
func renderMultiAgentPrompt(prompt string, values map[string]string) string {
rendered := prompt
for key, value := range values {
rendered = strings.ReplaceAll(rendered, "{{"+key+"}}", value)
}
return rendered
}
func multiAgentConversationShouldStop(output string, stopConditions []string) bool {
normalizedOutput := strings.ToUpper(output)
for _, condition := range stopConditions {
condition = strings.TrimSpace(condition)
if condition == "" {
continue
}
if strings.Contains(normalizedOutput, strings.ToUpper(condition)) {
return true
}
}
return false
}
func parseMultiAgentStringList(raw any) []string {
switch values := raw.(type) {
case []string:
return append([]string(nil), values...)
case []any:
result := make([]string, 0, len(values))
for _, value := range values {
text := strings.TrimSpace(fmt.Sprint(value))
if text != "" && text != "<nil>" {
result = append(result, text)
}
}
return result
default:
return nil
}
}
func withMultiAgentWorkingDirectory(params map[string]any, workingDirectory string) map[string]any {
next := make(map[string]any, len(params)+1)
for key, value := range params {
next[key] = value
}
if workingDirectory != "" {
next["workingDirectory"] = workingDirectory
}
return next
}
func safeMultiAgentPathSegment(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return "default"
}
var builder strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= 'A' && r <= 'Z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '.', r == '_', r == '-':
builder.WriteRune(r)
default:
builder.WriteByte('-')
}
}
if builder.Len() == 0 {
return "default"
}
return builder.String()
}