mirror of
https://gh-proxy.org/https://github.com/klinsmaya/multica.git
synced 2026-11-08 17:36:45 +08:00
* feat(agent): add host-local Devin CLI over ACP Spawn `devin acp` via the shared ACP client. Local CLI only — not cloud Devin VMs/Playbooks/Secrets. Model via `devin acp --model`. Catalog from `devin models list --format json` (families/variants). * fix: skip Devin ACP handshake when browser auth is also offered * fix: close dim case string so Devin compiles Missing quote after dim broke runtime_config.go and every Go CI job. * fix: stop runtimeConfigPath recursion on Devin Devin is a builtin whose ProtocolFamily is itself. Recursing on that overflowed the stack and killed backend-tests. * test(agent): cover Devin model failures and final-answer delivery Co-authored-by: multica-agent <[email protected]> * fix(agent): honor Devin model aliases and stored login (MUL-6623) Co-authored-by: multica-agent <[email protected]> * fix(agent): drain Kiro panic details before terminating runtime (MUL-6623) Co-authored-by: multica-agent <[email protected]> --------- Co-authored-by: Sol-Boy <[email protected]> Co-authored-by: multica-agent <[email protected]>
176 lines
5.7 KiB
Go
176 lines
5.7 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
type devinModelChoice struct {
|
|
Value string `json:"value"`
|
|
Name string `json:"name"`
|
|
Options []devinModelChoice `json:"options"`
|
|
}
|
|
|
|
type devinModelOption struct {
|
|
ID string `json:"id"`
|
|
Category string `json:"category"`
|
|
CurrentValue string `json:"currentValue"`
|
|
Options []devinModelChoice `json:"options"`
|
|
}
|
|
|
|
func devinSessionModel(raw json.RawMessage) devinModelOption {
|
|
var response struct {
|
|
ConfigOptions []json.RawMessage `json:"configOptions"`
|
|
}
|
|
if json.Unmarshal(raw, &response) == nil {
|
|
for _, rawOption := range response.ConfigOptions {
|
|
var option devinModelOption
|
|
if json.Unmarshal(rawOption, &option) == nil && (option.Category == "model" || option.ID == "model") {
|
|
return option
|
|
}
|
|
}
|
|
}
|
|
// Older CLI builds expose the same selected-model fact in models.
|
|
return devinModelOption{CurrentValue: extractACPCurrentModelID(raw)}
|
|
}
|
|
|
|
func devinModelAvailable(choices []devinModelChoice, value string) bool {
|
|
for _, choice := range choices {
|
|
if choice.Value == value || devinModelAvailable(choice.Options, value) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func normalizeDevinModelName(value string) string {
|
|
return strings.Map(func(r rune) rune {
|
|
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
|
return unicode.ToLower(r)
|
|
}
|
|
return -1
|
|
}, value)
|
|
}
|
|
|
|
func devinNamedModelValues(choices []devinModelChoice, name string, values map[string]bool) {
|
|
for _, choice := range choices {
|
|
if choice.Value != "" && normalizeDevinModelName(choice.Name) == name {
|
|
values[choice.Value] = true
|
|
}
|
|
devinNamedModelValues(choice.Options, name, values)
|
|
}
|
|
}
|
|
|
|
// Catalog aliases identify a family, never account entitlement. Do not infer
|
|
// versions from opaque model IDs or guess a winner across ambiguous families.
|
|
func devinCatalogModelValues(data []byte, requested string) (map[string]bool, error) {
|
|
var catalog struct {
|
|
Families []struct {
|
|
Slug string `json:"slug"`
|
|
Name string `json:"family_label"`
|
|
Aliases []string `json:"aliases"`
|
|
Variants []struct {
|
|
ID string `json:"model_uid"`
|
|
Label string `json:"label"`
|
|
} `json:"variants"`
|
|
} `json:"families"`
|
|
}
|
|
if err := json.Unmarshal(data, &catalog); err != nil {
|
|
return nil, fmt.Errorf("read Devin model aliases: %w", err)
|
|
}
|
|
name := normalizeDevinModelName(requested)
|
|
values := make(map[string]bool)
|
|
if name == "" {
|
|
return values, nil
|
|
}
|
|
matchedFamilies := 0
|
|
for _, family := range catalog.Families {
|
|
familyMatch := false
|
|
for _, alias := range append([]string{family.Slug, family.Name}, family.Aliases...) {
|
|
if normalizeDevinModelName(alias) == name {
|
|
familyMatch = true
|
|
break
|
|
}
|
|
}
|
|
matched := false
|
|
for _, variant := range family.Variants {
|
|
if variant.ID != "" && (familyMatch || variant.ID == requested || normalizeDevinModelName(variant.Label) == name) {
|
|
values[variant.ID] = true
|
|
matched = true
|
|
}
|
|
}
|
|
if matched {
|
|
matchedFamilies++
|
|
}
|
|
}
|
|
if matchedFamilies > 1 {
|
|
return nil, fmt.Errorf("Devin model name %q matches multiple families; choose a canonical model ID", requested)
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
// confirmDevinModel treats the session's state, not the launch flag or global
|
|
// catalog, as authoritative. The CLI can accept --model and silently use its
|
|
// configured default when that model is unavailable to the account. Session
|
|
// configOptions also let a resumed conversation apply a changed model choice.
|
|
func confirmDevinModel(ctx context.Context, request acpRequestFn, sessionID, requested string, raw json.RawMessage, catalog func(context.Context) ([]byte, error)) (string, error) {
|
|
option := devinSessionModel(raw)
|
|
requested = strings.TrimSpace(requested)
|
|
if requested == "" || requested == option.CurrentValue {
|
|
return option.CurrentValue, nil
|
|
}
|
|
values := map[string]bool{requested: true}
|
|
if !devinModelAvailable(option.Options, requested) {
|
|
values = make(map[string]bool)
|
|
if name := normalizeDevinModelName(requested); name != "" {
|
|
devinNamedModelValues(option.Options, name, values)
|
|
}
|
|
if len(values) > 1 {
|
|
return "", fmt.Errorf("Devin model name %q matches multiple session choices; choose a canonical model ID", requested)
|
|
}
|
|
if len(values) == 0 && catalog != nil {
|
|
data, err := catalog(ctx)
|
|
if err != nil {
|
|
return "", fmt.Errorf("resolve Devin model %q: %w; choose a canonical model ID", requested, err)
|
|
}
|
|
values, err = devinCatalogModelValues(data, requested)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
}
|
|
if option.CurrentValue != "" && values[option.CurrentValue] {
|
|
return option.CurrentValue, nil
|
|
}
|
|
// A fresh CLI may already have resolved an alias above. On resume, set a
|
|
// canonical advertised value only if there is one unambiguous candidate.
|
|
selected := ""
|
|
for value := range values {
|
|
if devinModelAvailable(option.Options, value) {
|
|
if selected != "" {
|
|
return "", fmt.Errorf("Devin model name %q has multiple available variants; choose a canonical model ID", requested)
|
|
}
|
|
selected = value
|
|
}
|
|
}
|
|
if option.ID == "" || selected == "" {
|
|
return "", fmt.Errorf("requested Devin model %q is unavailable or cannot be confirmed (session model %q); choose an available model ID or use the CLI default", requested, option.CurrentValue)
|
|
}
|
|
result, err := request(ctx, "session/set_config_option", map[string]any{
|
|
"sessionId": sessionID,
|
|
"configId": option.ID,
|
|
"value": selected,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("set Devin model %q: %w", requested, err)
|
|
}
|
|
actual := devinSessionModel(result).CurrentValue
|
|
if actual != selected {
|
|
return "", fmt.Errorf("Devin did not confirm requested model %q (session model %q); no prompt was sent", requested, actual)
|
|
}
|
|
return actual, nil
|
|
}
|