Files
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

462 lines
15 KiB
Go

package runtimeprep
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
)
var ErrCwdNotDirectory = errors.New("agent runtime cwd is not a directory")
type DefaultPreparer struct {
StateDir string
CLICommand string
CommandCatalog CommandCatalog
Store RuntimeStore
// BrowserUseAvailable is an optional live readiness probe. A nil probe
// preserves configuration-only behavior for embedders without a browser service.
BrowserUseAvailable func() bool
ComputerUseAvailable func() bool
// RTKExecutableResolver resolves the Tutti-owned RTK binary for an enabled
// Session. Production hosts provide a bundled or managed-runtime resolver;
// standalone embedders must provide the same exact-path contract.
RTKExecutableResolver func(context.Context) (string, error)
Profile DeploymentProfile
SkillSources []SkillSource
providers map[string]ProviderPreparer
cleanupMu sync.Mutex
providerCleanup map[string]func(context.Context) error
}
func NewDefaultPreparer(stateDir string) *DefaultPreparer {
preparer := &DefaultPreparer{
StateDir: stateDir,
Profile: StandardProfile(),
providers: make(map[string]ProviderPreparer),
providerCleanup: make(map[string]func(context.Context) error),
}
preparer.RegisterProvider(CodexPreparer{})
preparer.RegisterProvider(ClaudeCodePreparer{StateDir: stateDir})
preparer.RegisterProvider(CursorPreparer{})
preparer.RegisterProvider(OpenCodePreparer{})
preparer.RegisterProvider(InstructionFilePreparer{ProviderID: "nexight", FileName: "AGENTS.md"})
preparer.RegisterProvider(InstructionFilePreparer{ProviderID: "openclaw", FileName: "AGENTS.md"})
return preparer
}
func (p *DefaultPreparer) RegisterProvider(provider ProviderPreparer) {
if provider == nil {
return
}
providerID := strings.TrimSpace(provider.Provider())
if providerID == "" {
return
}
if p.providers == nil {
p.providers = make(map[string]ProviderPreparer)
}
p.providers[providerID] = provider
}
func (p *DefaultPreparer) Prepare(ctx context.Context, input PrepareInput) (PreparedRuntime, error) {
input = expandConnectorAgentContext(input)
workspaceID := strings.TrimSpace(input.WorkspaceID)
agentSessionID := strings.TrimSpace(input.AgentSessionID)
providerID := strings.TrimSpace(input.Provider)
cwd := strings.TrimSpace(input.Cwd)
if workspaceID == "" || agentSessionID == "" || providerID == "" {
return PreparedRuntime{}, errors.New("agent runtime prepare requires workspace, session, and provider")
}
if cwd == "" {
return PreparedRuntime{}, errors.New("agent runtime prepare requires cwd")
}
input.WorkspaceID = workspaceID
input.AgentSessionID = agentSessionID
input.Provider = providerID
input.Cwd = cwd
logRuntimePrepareTrace("runtime_prepare.entered", input, map[string]any{
"provider": providerID,
"cwd": cwd,
})
if err := ensureCwdDirectory(cwd); err != nil {
return PreparedRuntime{}, err
}
logRuntimePrepareTrace("runtime_prepare.cwd_checked", input, nil)
input = p.normalizeCapabilities(input)
if facts, factsErr := normalizeHostFacts(p.Profile.HostFacts); factsErr == nil {
input.hostFacts = facts
}
input.CLICommand = firstNonEmptyText(input.CLICommand, p.CLICommand, resolveCLICommand(p.StateDir))
resolver, err := resolveCommandCapabilities(
ctx,
p.CommandCatalog,
workspaceID,
input.CLICommand,
input.CommandCapabilityProjection,
)
if err != nil {
return PreparedRuntime{}, err
}
input.commandCapabilities = resolver
store := p.runtimeStore()
runtimeRoot, err := store.RuntimeRoot(workspaceID, agentSessionID)
if err != nil {
return PreparedRuntime{}, err
}
logRuntimePrepareTrace("runtime_prepare.runtime_root_resolved", input, nil)
if err := store.EnsureRuntimeRoot(runtimeRoot); err != nil {
return PreparedRuntime{}, err
}
logRuntimePrepareTrace("runtime_prepare.runtime_root_ensured", input, nil)
manifest := NewManifest(ManifestInput{
AgentSessionID: agentSessionID,
Provider: providerID,
Cwd: cwd,
RuntimeRoot: runtimeRoot,
})
resolved, err := resolveCapabilities(ctx, input, p.Profile, p.SkillSources)
if err != nil {
return PreparedRuntime{}, err
}
input.resolved = resolved
commandGuide, err := input.commandCapabilities.Guide()
if err != nil {
return PreparedRuntime{}, err
}
logRuntimePrepareTrace("runtime_prepare.input_normalized", input, map[string]any{
"cli_command": input.CLICommand,
"command_guide_length": len(commandGuide),
})
result := ProviderPrepareResult{Cwd: cwd}
var rtkRuntime sessionRTKRuntime
if input.RTKSaverMode {
rtkRuntime, err = prepareSessionRTK(runtimeRoot, func() (string, error) {
return p.resolveRTKExecutable(ctx)
})
if err != nil {
return PreparedRuntime{}, err
}
manifest.RecordManagedFile(rtkRuntime.Executable, "rtk-executable", rtkRuntime.ExecutableCreated)
manifest.RecordManagedFile(rtkRuntime.Instructions, "rtk-instructions", rtkRuntime.InstructionsCreated)
input.rtkInstructionsPath = rtkRuntime.Instructions
}
if provider := p.provider(input); provider != nil {
logRuntimePrepareTrace("runtime_prepare.provider_requested", input, map[string]any{
"provider": providerID,
})
result, err = provider.Prepare(ctx, ProviderPrepareInput{
PrepareInput: input,
RuntimeRoot: runtimeRoot,
Manifest: manifest,
Store: store,
})
if err != nil {
return PreparedRuntime{}, err
}
logRuntimePrepareTrace("runtime_prepare.provider_resolved", input, map[string]any{
"provider_env_count": len(result.Env),
"cwd": result.Cwd,
})
}
if result.Cwd == "" {
result.Cwd = cwd
}
if input.RTKSaverMode {
result.Env = append(result.Env, rtkRuntime.Env...)
}
runtimeEnv := defaultRuntimeEnv(input, p.StateDir)
if input.RTKSaverMode {
runtimeEnv = prependPathEntry(runtimeEnv, filepath.Dir(rtkRuntime.Executable))
}
result.Env = append(runtimeEnv, result.Env...)
logRuntimePrepareTrace("runtime_prepare.env_prepared", input, map[string]any{
"env_count": len(result.Env),
})
if err := store.SaveManifest(runtimeRoot, manifest); err != nil {
if result.Cleanup != nil {
_ = result.Cleanup(ctx)
}
return PreparedRuntime{}, err
}
if result.Cleanup != nil {
p.rememberProviderCleanup(workspaceID, agentSessionID, result.Cleanup)
}
logRuntimePrepareTrace("runtime_prepare.manifest_saved", input, nil)
return PreparedRuntime{
Cwd: result.Cwd,
Env: result.Env,
MCPServers: cloneMCPServerBindings(input.MCPServers),
}, nil
}
func (p *DefaultPreparer) resolveRTKExecutable(ctx context.Context) (string, error) {
if p.RTKExecutableResolver != nil {
path, err := p.RTKExecutableResolver(ctx)
if err != nil {
return "", fmt.Errorf("resolve Tutti-managed rtk executable: %w", err)
}
if strings.TrimSpace(path) == "" {
return "", errors.New("tutti-managed rtk executable is unavailable")
}
return path, nil
}
return "", errors.New("rtk saver mode requires a Tutti-managed rtk executable resolver")
}
func cloneMCPServerBindings(input []MCPServerBinding) []MCPServerBinding {
if len(input) == 0 {
return nil
}
result := make([]MCPServerBinding, 0, len(input))
for _, binding := range input {
headers := make(map[string]string, len(binding.Headers))
for key, value := range binding.Headers {
headers[key] = value
}
binding.Headers = headers
result = append(result, binding)
}
return result
}
func (p *DefaultPreparer) RenderSkillBundle(ctx context.Context, input PrepareInput) (SkillBundle, error) {
input = expandConnectorAgentContext(input)
workspaceID := strings.TrimSpace(input.WorkspaceID)
agentTargetID := strings.TrimSpace(input.AgentTargetID)
providerID := strings.TrimSpace(input.Provider)
if workspaceID == "" || agentTargetID == "" || providerID == "" {
return SkillBundle{}, errors.New("agent skill bundle render requires workspace, agent target, and provider")
}
input.WorkspaceID = workspaceID
input.AgentSessionID = strings.TrimSpace(input.AgentSessionID)
input.AgentTargetID = agentTargetID
input.Provider = providerID
input.CLICommand = firstNonEmptyText(input.CLICommand, p.CLICommand, resolveCLICommand(p.StateDir))
input = p.normalizeCapabilities(input)
if facts, factsErr := normalizeHostFacts(p.Profile.HostFacts); factsErr == nil {
input.hostFacts = facts
}
resolver, err := resolveCommandCapabilities(
ctx,
p.CommandCatalog,
workspaceID,
input.CLICommand,
input.CommandCapabilityProjection,
)
if err != nil {
return SkillBundle{}, err
}
input.commandCapabilities = resolver
resolved, err := resolveCapabilities(ctx, input, p.Profile, p.SkillSources)
if err != nil {
return SkillBundle{}, err
}
input.resolved = resolved
return renderProviderSkillBundle(input)
}
func (p *DefaultPreparer) Cleanup(ctx context.Context, input CleanupInput) error {
workspaceID := strings.TrimSpace(input.WorkspaceID)
agentSessionID := strings.TrimSpace(input.AgentSessionID)
if workspaceID == "" || agentSessionID == "" {
return errors.New("agent runtime cleanup requires workspace and session")
}
if cleanup := p.providerCleanupFor(workspaceID, agentSessionID); cleanup != nil {
if err := cleanup(ctx); err != nil {
return err
}
p.forgetProviderCleanup(workspaceID, agentSessionID)
}
runtimeRoot, err := p.runtimeStore().RuntimeRoot(workspaceID, agentSessionID)
if err != nil {
return err
}
if err := recoverMutagenAuthSessions(ctx, p.StateDir, runtimeRoot); err != nil {
return err
}
if input.PreserveRuntimeRoot {
return nil
}
return p.runtimeStore().CleanupRuntime(StoreCleanupInput{
WorkspaceID: workspaceID,
AgentSessionID: agentSessionID,
})
}
func providerCleanupKey(workspaceID, agentSessionID string) string {
return workspaceID + "\x00" + agentSessionID
}
func (p *DefaultPreparer) rememberProviderCleanup(workspaceID, agentSessionID string, cleanup func(context.Context) error) {
p.cleanupMu.Lock()
defer p.cleanupMu.Unlock()
if p.providerCleanup == nil {
p.providerCleanup = make(map[string]func(context.Context) error)
}
key := providerCleanupKey(workspaceID, agentSessionID)
if _, exists := p.providerCleanup[key]; !exists {
p.providerCleanup[key] = cleanup
}
}
func (p *DefaultPreparer) providerCleanupFor(workspaceID, agentSessionID string) func(context.Context) error {
p.cleanupMu.Lock()
defer p.cleanupMu.Unlock()
return p.providerCleanup[providerCleanupKey(workspaceID, agentSessionID)]
}
func (p *DefaultPreparer) forgetProviderCleanup(workspaceID, agentSessionID string) {
p.cleanupMu.Lock()
defer p.cleanupMu.Unlock()
delete(p.providerCleanup, providerCleanupKey(workspaceID, agentSessionID))
}
func ensureCwdDirectory(cwd string) error {
info, err := os.Stat(cwd)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("%w: %s", ErrCwdNotDirectory, cwd)
}
return fmt.Errorf("stat agent runtime cwd: %w", err)
}
if !info.IsDir() {
return fmt.Errorf("%w: %s", ErrCwdNotDirectory, cwd)
}
return nil
}
func (p *DefaultPreparer) provider(input PrepareInput) ProviderPreparer {
if p == nil {
return nil
}
providerID := strings.TrimSpace(input.Provider)
if provider := p.providers[providerID]; provider != nil {
return provider
}
if input.ExtensionRuntimePrep != nil {
return ExtensionRuntimePreparer{}
}
if strings.EqualFold(providerID, "acp:kimi-code") {
return KimiCodePreparer{}
}
// Other acp: extensions share a generic instruction+skill preparer; their
// skill roots arrive via PrepareInput.ExtensionSkillRoots from the
// extension composer profile, so they need no per-key entry.
if strings.HasPrefix(providerID, "acp:") {
return InstructionFilePreparer{}
}
return nil
}
func (p *DefaultPreparer) runtimeStore() RuntimeStore {
if p.Store != nil {
return p.Store
}
return LocalStore{StateDir: p.StateDir}
}
func (p *DefaultPreparer) normalizeCapabilities(input PrepareInput) PrepareInput {
if input.BrowserUse && p != nil && p.BrowserUseAvailable != nil {
input.BrowserUse = p.BrowserUseAvailable()
}
if input.ComputerUse && p != nil && p.ComputerUseAvailable != nil {
input.ComputerUse = p.ComputerUseAvailable()
}
return input
}
func defaultRuntimeEnv(input PrepareInput, stateDir string) []string {
env := []string{
"TUTTI_WORKSPACE_ID=" + strings.TrimSpace(input.WorkspaceID),
"TUTTI_AGENT_SESSION_ID=" + strings.TrimSpace(input.AgentSessionID),
"TUTTI_AGENT_TARGET_ID=" + strings.TrimSpace(input.AgentTargetID),
"TUTTI_AGENT_PROVIDER=" + strings.TrimSpace(input.Provider),
"TUTTI_AGENT_CWD=" + strings.TrimSpace(input.Cwd),
}
connectorBinDir := ""
if input.Connector != nil {
connectorBinDir = input.Connector.CLIBinDir
}
if pathEnv := runtimePathEnv(stateDir, connectorBinDir); pathEnv != "" {
env = append(env, pathEnv)
}
// Browser use is delivered as a default MCP server to every agent provider,
// so it is advertised here in the shared runtime env rather than per-provider.
if input.resolved != nil {
env = append(env, input.resolved.EnvOverlay...)
} else {
env = append(env, browserUseEnv(input.BrowserUse)...)
env = append(env, computerUseEnv(input.ComputerUse)...)
}
return env
}
func runtimePathEnv(stateDir string, connectorBinDir string) string {
stateDir = strings.TrimSpace(stateDir)
connectorBinDir = strings.TrimSpace(connectorBinDir)
if stateDir == "" && connectorBinDir == "" {
return ""
}
binDirs := make([]string, 0, 2)
if stateDir != "" {
binDirs = append(binDirs, filepath.Join(stateDir, "bin"))
}
if connectorBinDir != "" {
binDirs = append(binDirs, connectorBinDir)
}
currentPath := os.Getenv("PATH")
entries := filepath.SplitList(currentPath)
for _, binDir := range binDirs {
present := false
for _, entry := range entries {
if filepath.Clean(entry) == filepath.Clean(binDir) {
present = true
break
}
}
if !present {
entries = append([]string{binDir}, entries...)
}
}
return "PATH=" + strings.Join(entries, string(os.PathListSeparator))
}
func prependPathEntry(env []string, entry string) []string {
entry = strings.TrimSpace(entry)
if entry == "" {
return env
}
for index, value := range env {
if !strings.HasPrefix(value, "PATH=") {
continue
}
entries := filepath.SplitList(strings.TrimPrefix(value, "PATH="))
for _, candidate := range entries {
if filepath.Clean(candidate) == filepath.Clean(entry) {
return env
}
}
env[index] = "PATH=" + strings.Join(append([]string{entry}, entries...), string(os.PathListSeparator))
return env
}
return append(env, "PATH="+entry+string(os.PathListSeparator)+os.Getenv("PATH"))
}
func expandConnectorAgentContext(input PrepareInput) PrepareInput {
if input.Connector == nil {
return input
}
input.MCPServers = append(cloneMCPServerBindings(input.MCPServers), cloneMCPServerBindings(input.Connector.MCPServers)...)
input.ConnectorRoutingHints = append([]ConnectorRoutingHint(nil), input.Connector.RoutingHints...)
return input
}