Files
tutti/services/tuttid/service/agent/codex_capability_catalog.go
agent-radar 2ed3415fb7
Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
2026-09-01 20:17:27 +08:00

689 lines
20 KiB
Go

package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"os"
"strings"
"time"
"github.com/tutti-os/tutti/packages/agent/daemon/providerregistry"
"github.com/tutti-os/tutti/packages/agent/daemon/runtimecmd"
)
// Codex and Tutti Agent capability catalogs use the same app-server startup
// path as model/list, including the provider's first model metadata refresh.
// Keep this bounded independently from the composer request so a cold Windows
// npm shim does not turn a valid capability catalog into a false failure.
const codexAppServerCapabilityListTimeout = 30 * time.Second
type appServerCatalogRequestSet string
const (
appServerCatalogRequestSetCodex appServerCatalogRequestSet = "codex"
appServerCatalogRequestSetSkillsOnly appServerCatalogRequestSet = "skills_only"
)
type CodexCLICapabilityLister struct {
Command string
Args []string
Timeout time.Duration
RequestSet appServerCatalogRequestSet
Environ func() []string
HomeDir func() (string, error)
IsExecutableFile func(string) bool
LookPath func(string) (string, error)
}
type defaultComposerCapabilityLister struct{}
func (defaultComposerCapabilityLister) ListComposerCapabilityOptions(
ctx context.Context,
provider string,
cwd string,
fallbackSkills []ComposerSkillOption,
) ([]ComposerCapabilityOption, []string) {
return discoverComposerCapabilityOptions(ctx, provider, cwd, fallbackSkills)
}
func (s *Service) composerCapabilityLister() ComposerCapabilityLister {
if s.CapabilityLister != nil {
return s.CapabilityLister
}
return defaultComposerCapabilityLister{}
}
func discoverComposerCapabilityOptions(
ctx context.Context,
provider string,
cwd string,
fallbackSkills []ComposerSkillOption,
) ([]ComposerCapabilityOption, []string) {
fallback := composerCapabilityCatalogFromSkills(provider, fallbackSkills)
lister, ok, err := composerCapabilityCatalogLister(composerProfileFor(provider))
if err != nil {
return fallback, []string{err.Error()}
}
if !ok {
return fallback, nil
}
options, err := lister.List(ctx, cwd)
if err != nil {
return fallback, []string{err.Error()}
}
return mergeCodexComposerCapabilityOptions(fallback, options), nil
}
func mergeCodexComposerCapabilityOptions(
fallback []ComposerCapabilityOption,
native []ComposerCapabilityOption,
) []ComposerCapabilityOption {
result := append([]ComposerCapabilityOption(nil), fallback...)
sameSkillFile := newComposerSkillFileIdentityMatcher()
for _, option := range native {
replacedFallback := false
for index := range result {
if result[index].Kind == "skill" && option.Kind == "skill" && sameSkillFile(result[index].Path, option.Path) {
result[index] = option
replacedFallback = true
break
}
}
if !replacedFallback {
result = append(result, option)
}
}
return dedupeComposerCapabilityOptions(result)
}
func newComposerSkillFileIdentityMatcher() func(string, string) bool {
fileInfoByPath := make(map[string]os.FileInfo)
missingFileInfo := make(map[string]struct{})
fileInfo := func(path string) (os.FileInfo, bool) {
path = strings.TrimSpace(path)
if path == "" {
return nil, false
}
if info, ok := fileInfoByPath[path]; ok {
return info, true
}
if _, ok := missingFileInfo[path]; ok {
return nil, false
}
info, err := os.Stat(path)
if err != nil || info.IsDir() {
missingFileInfo[path] = struct{}{}
return nil, false
}
fileInfoByPath[path] = info
return info, true
}
return func(leftPath string, rightPath string) bool {
leftInfo, leftOK := fileInfo(leftPath)
rightInfo, rightOK := fileInfo(rightPath)
return leftOK && rightOK && os.SameFile(leftInfo, rightInfo)
}
}
func composerCapabilityCatalogLister(profile composerProfile) (CodexCLICapabilityLister, bool, error) {
switch profile.CapabilityCatalogKind {
case "":
return CodexCLICapabilityLister{}, false, nil
case providerregistry.CapabilityCatalogKindCodexAppServer, providerregistry.CapabilityCatalogKindAppServerSkills:
command := append([]string(nil), profile.CapabilityCatalogCommand...)
if len(command) == 0 || strings.TrimSpace(command[0]) == "" {
return CodexCLICapabilityLister{}, false, fmt.Errorf("capability catalog command is required")
}
for index, argument := range command[1:] {
if strings.TrimSpace(argument) == "" {
return CodexCLICapabilityLister{}, false, fmt.Errorf("capability catalog command argument %d is empty", index+1)
}
}
requestSet := appServerCatalogRequestSetCodex
if profile.CapabilityCatalogKind == providerregistry.CapabilityCatalogKindAppServerSkills {
requestSet = appServerCatalogRequestSetSkillsOnly
}
return CodexCLICapabilityLister{
Command: command[0],
Args: command[1:],
RequestSet: requestSet,
}, true, nil
default:
return CodexCLICapabilityLister{}, false, fmt.Errorf("unsupported capability catalog kind %q", profile.CapabilityCatalogKind)
}
}
func (l CodexCLICapabilityLister) List(ctx context.Context, cwd string) ([]ComposerCapabilityOption, error) {
startedAt := time.Now()
slog.Info("agent capability catalog fetch started",
"event", "agent.capability_catalog.fetch_start",
"provider", "codex",
"request_set", l.RequestSet,
)
timeout := l.Timeout
if timeout <= 0 {
timeout = codexAppServerCapabilityListTimeout
}
command := strings.TrimSpace(l.Command)
if command == "" {
return nil, fmt.Errorf("capability catalog command is required")
}
resolver := runtimecmd.Resolver{
Environ: l.Environ,
HomeDir: l.HomeDir,
IsExecutableFile: l.IsExecutableFile,
LookPath: l.LookPath,
}
processEnv := resolver.Env(nil)
command = resolver.Resolve(command, processEnv)
args := append([]string{}, l.Args...)
processCtx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
process, err := startCodexAppServerProcess(processCtx, command, args, processEnv)
if err != nil {
slog.Info("agent capability catalog fetch settled",
"event", "agent.capability_catalog.fetch_settled",
"provider", "codex",
"request_set", l.RequestSet,
"durationMs", time.Since(startedAt).Milliseconds(),
"error", err,
)
return nil, err
}
slog.Info("agent capability catalog process launch",
"event", "agent.capability_catalog.process_start",
"provider", "codex",
"command", command,
"args", args,
)
requestStartedAt := time.Now()
if err := writeAppServerCapabilityListRequests(process.stdin, cwd, l.RequestSet); err != nil {
slog.Info("agent capability catalog request stage settled",
"event", "agent.capability_catalog.stage_settled",
"provider", "codex",
"request_set", l.RequestSet,
"stage", "request_dispatch",
"durationMs", time.Since(requestStartedAt).Milliseconds(),
"error", err,
)
processErr := processCtx.Err()
_ = process.stop(cancel)
if processErr != nil {
return nil, fmt.Errorf("codex app-server capability discovery timed out: %w", processErr)
}
return nil, err
}
slog.Info("agent capability catalog request stage settled",
"event", "agent.capability_catalog.stage_settled",
"provider", "codex",
"request_set", l.RequestSet,
"stage", "request_dispatch",
"durationMs", time.Since(requestStartedAt).Milliseconds(),
)
responseStartedAt := time.Now()
options, err := readAppServerCapabilityListResponses(process.stdout, l.RequestSet)
slog.Info("agent capability catalog response stage settled",
"event", "agent.capability_catalog.stage_settled",
"provider", "codex",
"request_set", l.RequestSet,
"stage", "capability_response",
"durationMs", time.Since(responseStartedAt).Milliseconds(),
"optionCount", len(options),
"error", err,
)
processErr := processCtx.Err()
_ = process.stop(cancel)
settled := func(settledErr error) {
slog.Info("agent capability catalog fetch settled",
"event", "agent.capability_catalog.fetch_settled",
"provider", "codex",
"request_set", l.RequestSet,
"durationMs", time.Since(startedAt).Milliseconds(),
"optionCount", len(options),
"error", settledErr,
)
}
if err == nil {
settled(nil)
return options, nil
}
if processErr != nil {
timeoutErr := fmt.Errorf("codex app-server capability discovery timed out: %w", processErr)
settled(timeoutErr)
return nil, timeoutErr
}
if stderr := strings.TrimSpace(process.stderr.String()); stderr != "" {
stderrErr := fmt.Errorf("%w: %s", err, stderr)
settled(stderrErr)
return nil, stderrErr
}
settled(err)
return nil, err
}
func writeAppServerCapabilityListRequests(
stdin io.Writer,
cwd string,
requestSet appServerCatalogRequestSet,
) error {
requests, _, err := appServerCatalogRequests(cwd, requestSet)
if err != nil {
return err
}
encoder := json.NewEncoder(stdin)
if err := encoder.Encode(map[string]any{
"id": "1",
"method": "initialize",
"params": map[string]any{
"clientInfo": map[string]string{
"name": "tuttid",
"version": "0.1.0",
},
"capabilities": map[string]any{
"experimentalApi": true,
},
},
}); err != nil {
return fmt.Errorf("write codex app-server initialize: %w", err)
}
if err := encoder.Encode(map[string]any{
"method": "initialized",
"params": map[string]any{},
}); err != nil {
return fmt.Errorf("write codex app-server initialized: %w", err)
}
for _, request := range requests {
if err := encoder.Encode(request); err != nil {
return fmt.Errorf("write codex app-server %s: %w", request["method"], err)
}
}
return nil
}
func appServerCatalogRequests(
cwd string,
requestSet appServerCatalogRequestSet,
) ([]map[string]any, map[string]string, error) {
if requestSet == "" {
requestSet = appServerCatalogRequestSetCodex
}
cwds := []string{}
if trimmedCwd := strings.TrimSpace(cwd); trimmedCwd != "" {
cwds = append(cwds, trimmedCwd)
}
requests := []map[string]any{
{
"id": "2",
"method": "skills/list",
"params": map[string]any{
"cwds": cwds,
"forceReload": false,
},
},
}
pending := map[string]string{
"2": "skills/list",
}
switch requestSet {
case appServerCatalogRequestSetSkillsOnly:
return requests, pending, nil
case appServerCatalogRequestSetCodex:
default:
return nil, nil, fmt.Errorf("unsupported app-server catalog request set %q", requestSet)
}
requests = append(requests,
map[string]any{
"id": "3",
"method": "app/list",
"params": map[string]any{
"limit": 200,
"forceRefetch": false,
},
},
map[string]any{
"id": "4",
"method": "plugin/list",
"params": map[string]any{
"limit": 200,
},
},
map[string]any{
"id": "5",
"method": "mcpServerStatus/list",
"params": map[string]any{
"limit": 200,
"detail": "toolsAndAuthOnly",
},
},
)
pending["3"] = "app/list"
pending["4"] = "plugin/list"
pending["5"] = "mcpServerStatus/list"
return requests, pending, nil
}
func readAppServerCapabilityListResponses(
stdout io.Reader,
requestSet appServerCatalogRequestSet,
) ([]ComposerCapabilityOption, error) {
_, pendingMethods, err := appServerCatalogRequests("", requestSet)
if err != nil {
return nil, err
}
pending := make(map[string]struct{}, len(pendingMethods))
for id := range pendingMethods {
pending[id] = struct{}{}
}
scanner := bufio.NewScanner(stdout)
scanner.Buffer(make([]byte, 0, 64*1024), codexModelListMaxLineBytes)
options := make([]ComposerCapabilityOption, 0)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" {
continue
}
var payload map[string]json.RawMessage
if json.Unmarshal([]byte(line), &payload) != nil {
continue
}
id := codexRPCIDString(payload["id"])
if _, ok := pending[id]; !ok {
continue
}
delete(pending, id)
if rawError, ok := payload["error"]; ok && string(rawError) != "null" {
if len(pending) == 0 {
return dedupeComposerCapabilityOptions(options), nil
}
continue
}
switch id {
case "2":
options = append(options, parseCodexSkillCapabilities(payload["result"])...)
case "3":
options = append(options, parseCodexAppCapabilities(payload["result"])...)
case "4":
options = append(options, parseCodexPluginCapabilities(payload["result"])...)
case "5":
options = append(options, parseCodexMCPCapabilities(payload["result"])...)
}
if len(pending) == 0 {
return dedupeComposerCapabilityOptions(options), nil
}
}
if err := scanner.Err(); err != nil {
return nil, fmt.Errorf("read codex app-server stdout: %w", err)
}
if len(options) > 0 {
return dedupeComposerCapabilityOptions(options), nil
}
return nil, fmt.Errorf("codex app-server exited before capability responses")
}
func codexRPCIDString(raw json.RawMessage) string {
var stringID string
if err := json.Unmarshal(raw, &stringID); err == nil {
return stringID
}
var numberID int
if err := json.Unmarshal(raw, &numberID); err == nil {
return fmt.Sprintf("%d", numberID)
}
return ""
}
func parseCodexSkillCapabilities(raw json.RawMessage) []ComposerCapabilityOption {
var result struct {
Data []struct {
Skills []map[string]any `json:"skills"`
} `json:"data"`
}
if json.Unmarshal(raw, &result) != nil {
return nil
}
options := make([]ComposerCapabilityOption, 0)
for _, group := range result.Data {
for _, skill := range group.Skills {
name := codexTextValue(skill, "name")
if name == "" {
continue
}
label := firstNonEmptyString(codexTextValue(codexNestedMap(skill, "interface"), "displayName"), name)
description := firstNonEmptyString(
codexTextValue(codexNestedMap(skill, "interface"), "shortDescription"),
codexTextValue(skill, "description"),
)
status := "available"
if enabled, ok := codexBoolValue(skill, "enabled"); ok && !enabled {
status = "disabled"
}
path := codexTextValue(skill, "path")
options = append(options, ComposerCapabilityOption{
ID: "skill:" + name,
Kind: "skill",
Name: name,
Label: label,
Description: description,
Status: status,
Trigger: "$" + name,
Path: path,
Invocation: "promptItem",
})
}
}
return options
}
func parseCodexAppCapabilities(raw json.RawMessage) []ComposerCapabilityOption {
var result struct {
Data []map[string]any `json:"data"`
}
if json.Unmarshal(raw, &result) != nil {
return nil
}
options := make([]ComposerCapabilityOption, 0, len(result.Data))
for _, app := range result.Data {
id := codexTextValue(app, "id")
name := firstNonEmptyString(codexTextValue(app, "name"), id)
if id == "" || name == "" {
continue
}
status := "available"
if enabled, ok := codexBoolValue(app, "isEnabled"); ok && !enabled {
status = "disabled"
}
if accessible, ok := codexBoolValue(app, "isAccessible"); ok && !accessible {
status = "authRequired"
}
options = append(options, ComposerCapabilityOption{
ID: "connector:" + id,
Kind: "connector",
Name: id,
Label: name,
Description: codexTextValue(app, "description"),
Status: status,
Trigger: "$" + id,
Path: "app://" + id,
Invocation: "promptItem",
})
}
return options
}
func parseCodexPluginCapabilities(raw json.RawMessage) []ComposerCapabilityOption {
var result struct {
Data []map[string]any `json:"data"`
}
if json.Unmarshal(raw, &result) != nil {
return nil
}
options := make([]ComposerCapabilityOption, 0, len(result.Data))
for _, plugin := range result.Data {
name := firstNonEmptyString(codexTextValue(plugin, "name"), codexTextValue(plugin, "id"), codexTextValue(plugin, "pluginName"))
if name == "" {
continue
}
label := firstNonEmptyString(codexTextValue(plugin, "displayName"), codexTextValue(plugin, "title"), name)
options = append(options, ComposerCapabilityOption{
ID: "plugin:" + name,
Kind: "plugin",
Name: name,
Label: label,
Description: codexTextValue(plugin, "description"),
Status: "available",
Source: codexPluginSource(plugin),
PluginName: name,
Invocation: "none",
})
}
return options
}
func parseCodexMCPCapabilities(raw json.RawMessage) []ComposerCapabilityOption {
var result struct {
Data []map[string]any `json:"data"`
}
if json.Unmarshal(raw, &result) != nil {
return nil
}
options := make([]ComposerCapabilityOption, 0)
for _, server := range result.Data {
name := firstNonEmptyString(codexTextValue(server, "name"), codexTextValue(server, "serverName"))
if name == "" {
continue
}
status := normalizeCodexMCPStatus(codexTextValue(server, "status"))
options = append(options, ComposerCapabilityOption{
ID: "mcpServer:" + name,
Kind: "mcpServer",
Name: name,
Label: name,
Status: status,
ServerName: name,
Invocation: "none",
})
for _, tool := range codexSliceOfMaps(server["tools"]) {
toolName := firstNonEmptyString(codexTextValue(tool, "name"), codexTextValue(tool, "toolName"))
if toolName == "" {
continue
}
options = append(options, ComposerCapabilityOption{
ID: "mcpTool:" + name + "/" + toolName,
Kind: "mcpTool",
Name: toolName,
Label: toolName,
Description: codexTextValue(tool, "description"),
Status: status,
ServerName: name,
ToolName: toolName,
Invocation: "none",
})
}
}
return options
}
func normalizeCodexMCPStatus(status string) string {
normalized := strings.ToLower(strings.TrimSpace(status))
switch {
case strings.Contains(normalized, "auth"):
return "authRequired"
case strings.Contains(normalized, "fail"), strings.Contains(normalized, "error"), strings.Contains(normalized, "disabled"):
return "setupRequired"
default:
return "available"
}
}
func codexPluginSource(plugin map[string]any) string {
source := codexNestedMap(plugin, "source")
if source == nil {
return codexTextValue(plugin, "source")
}
return firstNonEmptyString(codexTextValue(source, "type"), codexTextValue(source, "url"), codexTextValue(source, "path"))
}
func codexTextValue(values map[string]any, key string) string {
if values == nil {
return ""
}
value, ok := values[key]
if !ok {
return ""
}
switch typed := value.(type) {
case string:
return strings.TrimSpace(typed)
case fmt.Stringer:
return strings.TrimSpace(typed.String())
default:
return ""
}
}
func codexBoolValue(values map[string]any, key string) (bool, bool) {
if values == nil {
return false, false
}
value, ok := values[key].(bool)
return value, ok
}
func codexNestedMap(values map[string]any, key string) map[string]any {
if values == nil {
return nil
}
value, _ := values[key].(map[string]any)
return value
}
func codexSliceOfMaps(value any) []map[string]any {
items, ok := value.([]any)
if !ok {
return nil
}
result := make([]map[string]any, 0, len(items))
for _, item := range items {
if record, ok := item.(map[string]any); ok {
result = append(result, record)
}
}
return result
}
func mergeComposerCapabilityOptions(left []ComposerCapabilityOption, right []ComposerCapabilityOption) []ComposerCapabilityOption {
if len(left) == 0 {
return dedupeComposerCapabilityOptions(right)
}
if len(right) == 0 {
return dedupeComposerCapabilityOptions(left)
}
return dedupeComposerCapabilityOptions(append(append([]ComposerCapabilityOption{}, left...), right...))
}
func dedupeComposerCapabilityOptions(options []ComposerCapabilityOption) []ComposerCapabilityOption {
if len(options) == 0 {
return []ComposerCapabilityOption{}
}
seen := map[string]struct{}{}
result := make([]ComposerCapabilityOption, 0, len(options))
for _, option := range options {
id := strings.TrimSpace(option.ID)
if id == "" {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
result = append(result, option)
}
return result
}