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

433 lines
16 KiB
Go

package agentruntime
import (
"context"
"encoding/json"
"strings"
"sync"
"time"
activityshared "github.com/tutti-os/tutti/packages/agent/daemon/activity/events"
)
type standardACPProviderMessageHandler func(
context.Context,
*acpClient,
Session,
string,
acpMessage,
*acpTurnNormalizer,
EventSink,
) ([]activityshared.Event, bool, error)
// standardACPLocalToolBridge projects provider-specific, Tutti-owned tools
// through the ACP session's standard HTTP MCP extension point. The returned
// release function owns the exact binding lease so a replaced process cannot
// revoke its successor's authority.
type standardACPLocalToolBridge interface {
Bind(context.Context, Session) (MCPServerBinding, func(), error)
ActivateTurn(Session, string, EventSink) func()
}
type standardACPConfig struct {
provider string
adapterName string
command []string
defaultTitle string
defaultTitleAliases []string
authRequiredMessage string
permissionModeID func(string) string
// initializeParams returns the initialize request params for this ACP provider.
// Some providers, such as Claude Agent, require richer terminal/auth capability
// declarations than the generic ACP defaults.
initializeParams func() map[string]any
// setModeParams returns extra JSON-RPC params merged into session/set_mode after sessionId and modeId.
setModeParams func(Session) map[string]any
failOnSetModeError bool
env func(Session) []string
commandResolver ProviderCommandResolver
beforeNewSession func(context.Context, *acpClient, Session, json.RawMessage) error
// retrySessionNewError permits a provider-specific, bounded retry for a
// session/new failure that is known to be transient. The retry happens on
// the already initialized connection, before a provider session id or user
// turn exists, so it cannot duplicate user work.
retrySessionNewError func(error) bool
sessionNewRetryLimit int
// validateNewSessionResult, when set, inspects the raw session/new response
// right after it succeeds and may reject the start (setup probes use it to
// catch agents that create a session they cannot actually serve).
validateNewSessionResult func(json.RawMessage) error
// validateSettings rejects provider-specific setting combinations both
// before startup and before live settings reach the provider. Generic ACP
// descriptors can expose provider-wide options even when the selected model
// narrows their support.
validateSettings func(Session, SessionSettingsPatch) error
// filterRuntimeConfigOptionDescriptors removes provider-invalid capability
// values before descriptors become the live RuntimeContext authority.
filterRuntimeConfigOptionDescriptors func(Session, []map[string]any) []map[string]any
// filterRuntimeConfigOptionValues removes provider-invalid current values
// before they become the SessionState settings/runtime-context authority.
filterRuntimeConfigOptionValues func(Session, map[string]any) map[string]any
// allowSyntheticNotice lets codex-acp-derived providers promote bare
// transport text ("Reconnecting... 1/5", "Falling back ... transport")
// streamed as ordinary chunks into system-notice banners instead of
// appending it to the assistant reply.
allowSyntheticNotice bool
// stderrMessageMapper translates provider stderr frames into synthetic
// session/update messages (e.g. codex-acp retry logs -> transport notices).
stderrMessageMapper acpStderrMessageMapper
// commandWithSettings appends session-settings-derived spawn arguments to
// the resolved command (e.g. codex-acp `--config model=...` flags that can
// only be applied at process start).
commandWithSettings func([]string, Session) []string
// initialPromptContext resolves provider-owned context that ACP v1 cannot
// carry through a developer/system channel. It is appended to the first
// provider prompt only and never projected as user-visible content.
initialPromptContext func(Session) (string, error)
// finalizeEnv applies provider-owned environment composition after session,
// target, and managed-runtime overrides have been resolved.
finalizeEnv func([]string, Session) ([]string, error)
// requiresNewSessionForSettings reports settings patches that can only
// take effect via a fresh process/session (spawn-time-only flags).
requiresNewSessionForSettings func(Session, SessionSettingsPatch) bool
// automaticPermissionDecision lets a provider resolve incoming
// session/request_permission requests without prompting, from the live
// permission tier. It returns a decision
// token ("approved" / "denied") to apply automatically, or "" to prompt
// the user as usual. Nil (the default) always prompts.
automaticPermissionDecision func(permissionModeID string) string
// providerPermissionRequestDecision resolves a narrowly recognized
// provider request before the permission tier is consulted. It is used only
// for non-mutating, Tutti-owned local tools whose own operation presents the
// real user interaction.
providerPermissionRequestDecision func(json.RawMessage) string
// filterPermissionOptions narrows provider-offered approval choices before
// they become a durable interaction. Providers use it only when an option
// would conflict with live permission-tier semantics.
filterPermissionOptions func([]map[string]any) []map[string]any
// deferApprovalUntilToolInput holds ordinary approvals whose permission frame
// omits display input until the matching tool update supplies it.
deferApprovalUntilToolInput bool
// autoContinueRetriableTurnError resumes turns the agent ends "normally"
// right after streaming a transient network error as plain text (Cursor's
// "Error: RetriableError: ..." tail). See acp_auto_continue.go.
autoContinueRetriableTurnError bool
applySessionMeta func(map[string]any, Session, HostMetadata)
planModeRuntimeID string
planModeDisabledRuntimeID string
planModeUsesLaunchPermission bool
projectCurrentMode bool
startupDiagnostics bool
toolAliases map[string]string
modelConfigOptionID string
modelDescriptionFormat string
permissionConfigOptionID string
reasoningConfigOptionID string
restrictConfigOptions bool
launchPermission *StandardACPLaunchPermissionSetting
setModelReasoningEffortMeta bool
messageDiagnostics *standardACPMessageDiagnostics
providerMessageHandler standardACPProviderMessageHandler
localToolBridge standardACPLocalToolBridge
capabilities []string
agentTargetID string
installationID string
executableIdentity *ExecutableIdentity
startupTimeout time.Duration
}
type standardACPMessageDiagnostics struct {
method string
observeMessage func(standardACPConfig, Session, string, acpMessage, *acpTurnNormalizer)
observeUpdate func(standardACPConfig, Session, string, string, map[string]any)
}
func (a *standardACPAdapter) MatchesAdapterResolveInput(input AdapterResolveInput) bool {
if a == nil || a.config.installationID == "" {
return true
}
installationID := strings.TrimSpace(asString(input.ProviderTargetRef["extensionInstallationId"]))
return strings.TrimSpace(input.AgentTargetID) == a.config.agentTargetID && installationID == a.config.installationID
}
type standardACPAdapter struct {
config standardACPConfig
transport ProcessTransport
host HostMetadata
preparer ProviderLaunchPreparer
mu sync.Mutex
sessions map[string]*standardACPSession
retiredSessions map[string][]*standardACPSession
terminalInteractions terminalInteractiveDispositionStore
interactiveDispositionSink InteractiveDispositionSink
commandSink CommandSnapshotSink
eventSink SessionEventSink
inputUnits *providerInputUnitTracker
configSink ConfigOptionsUpdateSink
promptImageMaterializer providerPromptImageMaterializer
lifecycleMu sync.Mutex
lifecycleLocks map[string]*standardACPSessionLock
}
type standardACPSession struct {
client *acpClient
// releasing fences new live-client work while idle release is closing the
// transport. The lifecycle lock serializes mutating entrypoints; this flag
// also makes liveness probes fail closed during the close call.
releasing bool
// releaseFailed keeps a physical client registered for another Close
// attempt while preventing new work from reusing its closed stdin.
releaseFailed bool
providerSessionID string
// resumeMethod records the capability proven by this process's initialize
// handshake. Idle release is safe only when the next process can restore
// the provider session through this method.
resumeMethod string
// resumeRuntimeContext preserves the historical adapter projection only
// when replay attaches at an already-initialized connection checkpoint.
resumeRuntimeContext map[string]any
agentInfo map[string]any
promptImage bool
sessionClose bool
acpLiveState
pendingApprovals map[string]*pendingACPApproval
recentTurnID string
recentTurnExpiry time.Time
// lifecycleSeq orders provider-agnostic authoritative turn snapshots
// emitted by the standard ACP adapter (ADR 0008).
lifecycleSeq uint64
// permissionModeID tracks the session's live permission tier so automatic
// approval or denial applies immediately after a mid-session tier change,
// without a respawn.
permissionModeID string
// planMode denies permission-gated operations even when the provider emits
// a request while its planning workflow is active.
planMode bool
// initialPromptContext remains pending until the provider accepts its first
// prompt. A failed transport call leaves it pending for the next attempt.
initialPromptContext string
// activePrompt fences cancellation against the provider's outstanding
// session/prompt request. A session/cancel notification is only delivery;
// the provider may still reject or queue another prompt until the original
// request has actually returned.
activePrompt *standardACPActivePrompt
localToolRelease func()
localToolReleaseOnce sync.Once
}
type standardACPActivePrompt struct {
done chan struct{}
cancelDeliveryDone chan struct{}
cancelRequested bool
}
func (session *standardACPSession) releaseLocalTools() {
if session == nil || session.localToolRelease == nil {
return
}
session.localToolReleaseOnce.Do(session.localToolRelease)
}
func (a *standardACPAdapter) resolveInitialPromptContext(session Session) (string, error) {
if a == nil || a.config.initialPromptContext == nil {
return "", nil
}
context, err := a.config.initialPromptContext(session)
if err != nil {
return "", err
}
return strings.TrimSpace(context), nil
}
func (a *standardACPAdapter) pendingInitialPromptContext(session *standardACPSession) string {
if a == nil || session == nil {
return ""
}
a.mu.Lock()
defer a.mu.Unlock()
return session.initialPromptContext
}
func (a *standardACPAdapter) consumeInitialPromptContext(session *standardACPSession) {
if a == nil || session == nil {
return
}
a.mu.Lock()
defer a.mu.Unlock()
session.initialPromptContext = ""
}
func (a *standardACPAdapter) stampTurnLifecycleSnapshots(acpSession *standardACPSession, events []activityshared.Event) []activityshared.Event {
if a == nil || acpSession == nil || len(events) == 0 {
return events
}
a.mu.Lock()
defer a.mu.Unlock()
return stampAdapterTurnLifecycleEvents(events, func() uint64 {
acpSession.lifecycleSeq++
return acpSession.lifecycleSeq
})
}
type standardACPSessionLock struct {
mu sync.Mutex
refs int
}
type pendingACPApproval = pendingInteractiveRequest
const standardACPRecentTurnTTL = 10 * time.Minute
const standardACPCancelDrainTimeout = time.Second
const acpMethodSetConfigOption = "session/set_config_option"
const acpMethodSetModel = "session/set_model"
const acpMethodCloseSession = "session/close"
const (
acpCloseCallTimeout = 750 * time.Millisecond
acpCloseGraceTimeout = 200 * time.Millisecond
)
func (a *standardACPAdapter) applyProviderSessionMeta(params map[string]any, session Session) error {
if params == nil {
return nil
}
if a.config.applySessionMeta != nil {
a.config.applySessionMeta(params, session, a.host)
}
return nil
}
func (a *standardACPAdapter) ValidatePromptContent(session Session, content []PromptContentBlock) error {
if !promptContentHasImage(content) {
return nil
}
if err := validatePromptContentImagesForPreflight(content); err != nil {
return err
}
acpSession := a.getSession(session.AgentSessionID)
if acpSession != nil && acpSession.promptImage {
return nil
}
return ErrPromptImageUnsupported
}
func standardACPPromptImageSupported(raw json.RawMessage) bool {
return acpPromptImageSupported(raw)
}
func standardACPProviderPromptImageSupported(provider string, raw json.RawMessage) bool {
if migratedProviderHasCapability(provider, CapabilityImageInput) {
return true
}
return standardACPPromptImageSupported(raw)
}
func standardACPSessionCloseSupported(raw json.RawMessage) bool {
var result struct {
SessionCapabilities map[string]bool `json:"sessionCapabilities"`
}
if err := json.Unmarshal(raw, &result); err != nil {
return false
}
return result.SessionCapabilities["close"]
}
func mergeACPParamsMeta(params map[string]any, meta map[string]any) {
if len(meta) == 0 {
return
}
existing, _ := params["_meta"].(map[string]any)
if existing == nil {
existing = map[string]any{}
params["_meta"] = existing
}
for key, value := range meta {
existing[key] = value
}
}
func sessionEnvValue(env []string, key string) string {
prefix := key + "="
for _, item := range env {
if strings.HasPrefix(item, prefix) {
return strings.TrimPrefix(item, prefix)
}
}
return ""
}
func standardACPInitialLiveState() acpLiveState {
return newACPLiveState()
}
func (a *standardACPAdapter) Provider() string {
if a == nil {
return ""
}
return a.config.provider
}
// UsesRootProviderTurnLifecycle keeps provider completion separate from the
// canonical root turn. Standard ACP does not currently expose durable child
// sessions, but it must still use the same daemon-owned settlement path as
// every other provider so adding an ACP child-session strategy cannot create a
// second completion model.
func (*standardACPAdapter) UsesRootProviderTurnLifecycle() bool {
return true
}
func (a *standardACPAdapter) SetCommandSnapshotSink(sink CommandSnapshotSink) {
if a == nil {
return
}
a.mu.Lock()
a.commandSink = sink
a.mu.Unlock()
}
func (a *standardACPAdapter) SetSessionEventSink(sink SessionEventSink) {
if a == nil {
return
}
a.mu.Lock()
a.eventSink = sink
a.mu.Unlock()
}
func (a *standardACPAdapter) SetProviderLaunchPreparer(preparer ProviderLaunchPreparer) {
if a == nil {
return
}
a.preparer = preparer
}
func (a *standardACPAdapter) lockSessionLifecycle(agentSessionID string) func() {
if a == nil {
return func() {}
}
key := strings.TrimSpace(agentSessionID)
a.lifecycleMu.Lock()
if a.lifecycleLocks == nil {
a.lifecycleLocks = make(map[string]*standardACPSessionLock)
}
lock := a.lifecycleLocks[key]
if lock == nil {
lock = &standardACPSessionLock{}
a.lifecycleLocks[key] = lock
}
lock.refs++
a.lifecycleMu.Unlock()
lock.mu.Lock()
return func() {
lock.mu.Unlock()
a.lifecycleMu.Lock()
lock.refs--
if lock.refs <= 0 && a.lifecycleLocks[key] == lock {
delete(a.lifecycleLocks, key)
}
a.lifecycleMu.Unlock()
}
}