Files
tutti/packages/agent/daemon/runtime/codex_appserver_model_settings.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

472 lines
16 KiB
Go

package agentruntime
import (
"context"
"encoding/json"
"log/slog"
"strings"
"github.com/tutti-os/tutti/packages/agent/daemon/modelcatalog"
)
// codexAppServerExecState owns the mutable model/config snapshot used to build
// one turn. liveSession points at the same immutable client/thread identity
// stored in the adapter; the remaining fields are copied while a.mu is held so
// ApplySessionSettings and the background model/list refresh cannot race with
// request construction.
type codexAppServerExecState struct {
liveSession *codexAppServerSession
models []map[string]any
config map[string]any
defaultModel string
}
// codexAppServerModelValue resolves the request model name from one model/list
// entry. `model` is the app-server request value; `id` is only a fallback for
// older catalogs that omit it.
func codexAppServerModelValue(model map[string]any) string {
return strings.TrimSpace(firstNonEmpty(asString(model["model"]), asString(model["id"])))
}
// codexAppServerDefaultModel resolves the default request model from
// model/list, used to satisfy the required CollaborationMode.settings.model
// field and to validate reasoning options for provider-default sessions.
func codexAppServerDefaultModel(models []map[string]any) string {
for _, model := range models {
if isDefault, _ := model["isDefault"].(bool); isDefault {
if value := codexAppServerModelValue(model); value != "" {
return value
}
}
}
for _, model := range models {
if value := codexAppServerModelValue(model); value != "" {
return value
}
}
return ""
}
func codexAppServerNeedsSynchronousModels(session Session) bool {
settings := session.SettingsValue()
// A missing model needs the catalog default. A persisted reasoning effort
// also needs the selected model's advertised options before thread/start or
// thread/resume, otherwise an obsolete value can make the request invalid.
// Explicit models without an effort keep the fast path and load the catalog
// asynchronously after startup.
return strings.TrimSpace(settings.Model) == "" ||
strings.TrimSpace(settings.ReasoningEffort) != ""
}
func codexAppServerSessionDefaultModel(session Session, models []map[string]any) string {
return firstNonEmpty(strings.TrimSpace(session.SettingsValue().Model), codexAppServerDefaultModel(models))
}
func cloneCodexAppServerModels(models []map[string]any) []map[string]any {
if len(models) == 0 {
return nil
}
cloned := make([]map[string]any, 0, len(models))
for _, model := range models {
cloned = append(cloned, clonePayloadDeep(model))
}
return cloned
}
// codexAppServerSessionWithConfig applies every present live config key,
// including empty-string tombstones. Presence matters while model/list is
// loading: an explicit clear must override the stale startup session value.
func codexAppServerSessionWithConfig(session Session, config map[string]any) Session {
settings := session.SettingsValue()
if model, ok := config["model"]; ok {
settings.Model = asString(model)
}
if reasoning, ok := config["reasoning_effort"]; ok {
settings.ReasoningEffort = asString(reasoning)
}
if speed, ok := config["service_tier"]; ok {
settings.Speed = asString(speed)
}
session.Settings = &settings
return session
}
// codexAppServerExecSessionWithConfig fills missing call-time settings from the
// live adapter state without replacing explicit settings carried by the
// controller's Exec session. ApplySessionSettings updates both controller state
// and the live config; preferring a stale live default here would otherwise
// undo valid model/effort overrides on the next turn.
func codexAppServerExecSessionWithConfig(session Session, config map[string]any) Session {
requested := session.SettingsValue()
session = codexAppServerSessionWithConfig(session, config)
effective := session.SettingsValue()
if value := strings.TrimSpace(requested.Model); value != "" {
effective.Model = value
}
if value := strings.TrimSpace(requested.ReasoningEffort); value != "" {
effective.ReasoningEffort = value
}
if value := strings.TrimSpace(requested.Speed); value != "" {
effective.Speed = value
}
session.Settings = &effective
return session
}
func (a *CodexAppServerAdapter) applyStartupModels(
agentSessionID string,
session Session,
threadResult json.RawMessage,
models []map[string]any,
) bool {
if len(models) == 0 {
return false
}
a.mu.Lock()
defer a.mu.Unlock()
appSession := a.sessions[strings.TrimSpace(agentSessionID)]
if appSession == nil {
return false
}
effectiveSession := codexAppServerSessionWithConfig(session, appSession.configOptions)
effectiveSettings := codexAppServerEffectiveSettings(models, effectiveSession, threadResult)
effectiveSession.Settings = &effectiveSettings
applyACPConfigOptionDescriptors(
&appSession.acpLiveState,
codexAppServerConfigOptionDescriptors(models, effectiveSession, threadResult),
)
appSession.models = cloneCodexAppServerModels(models)
appSession.defaultModel = codexAppServerSessionDefaultModel(effectiveSession, models)
appSession.startupModelsReady = true
return true
}
func codexAppServerConfigOptionDescriptors(
models []map[string]any,
session Session,
threadResult json.RawMessage,
) []map[string]any {
settings := codexAppServerEffectiveSettings(models, session, threadResult)
currentModel := settings.Model
currentEffort := settings.ReasoningEffort
modelOptions := make([]any, 0, len(models))
modelOptionValues := map[string]struct{}{}
var effortValues []string
effortValuesAdvertised := false
for _, model := range models {
value := codexAppServerModelValue(model)
if value == "" {
continue
}
// Hidden models still own the selected session's reasoning profile. Read
// that capability before filtering them out of the model picker.
if value == currentModel {
effortValues, effortValuesAdvertised = appServerSupportedEfforts(model)
}
if hidden, _ := model["hidden"].(bool); hidden {
continue
}
modelOption, ok := codexAppServerRuntimeModelOption(model)
if !ok {
continue
}
modelOptions = append(modelOptions, modelOption)
modelOptionValues[value] = struct{}{}
}
if currentModel != "" {
if _, ok := modelOptionValues[currentModel]; !ok {
modelOptions = append(modelOptions, map[string]any{
"value": currentModel,
"name": currentModel,
})
}
}
if !effortValuesAdvertised {
effortValues = []string{"minimal", "low", "medium", "high", "xhigh"}
}
effortOptions := make([]any, 0, len(effortValues))
for _, value := range effortValues {
effortOptions = append(effortOptions, map[string]any{
"value": value,
"name": strings.ToUpper(value[:1]) + value[1:],
})
}
descriptors := make([]map[string]any, 0, 3)
if len(modelOptions) > 0 {
descriptors = append(descriptors, map[string]any{
"id": "model",
"name": "Model",
"type": "select",
"category": "model",
"currentValue": currentModel,
"options": modelOptions,
})
}
reasoningCurrentValue := any(firstNonEmpty(currentEffort, "medium"))
if effortValuesAdvertised && len(effortOptions) == 0 {
// Keep an explicit empty live descriptor. It suppresses stale daemon
// catalog fallbacks and leaves an empty config tombstone for state
// projection without advertising an unsupported selector value.
reasoningCurrentValue = nil
}
descriptors = append(descriptors, map[string]any{
"id": "reasoning_effort",
"name": "Reasoning Effort",
"type": "select",
"category": "thought_level",
"currentValue": reasoningCurrentValue,
"options": effortOptions,
})
descriptors = append(descriptors, map[string]any{
"id": "service_tier",
"name": "Speed",
"type": "select",
"category": "speed",
"currentValue": firstNonEmpty(strings.TrimSpace(settings.Speed), "standard"),
"options": []any{
map[string]any{"value": "standard", "name": "Standard"},
map[string]any{"value": "fast", "name": "Fast"},
},
})
return descriptors
}
func codexAppServerRuntimeModelOption(model map[string]any) (map[string]any, bool) {
raw, err := json.Marshal(model)
if err != nil {
return nil, false
}
normalized, ok := modelcatalog.NormalizeCodexModel(raw)
if !ok {
return nil, false
}
return modelcatalog.ProjectRuntimeConfigOptionModel(normalized), true
}
func codexAppServerEffectiveSettings(
models []map[string]any,
session Session,
threadResult json.RawMessage,
) SessionSettings {
settings := session.SettingsValue()
currentModel := strings.TrimSpace(settings.Model)
currentEffort := strings.TrimSpace(settings.ReasoningEffort)
var threadInfo struct {
Model string `json:"model"`
ReasoningEffort string `json:"reasoningEffort"`
}
if len(threadResult) > 0 {
if err := json.Unmarshal(threadResult, &threadInfo); err == nil {
currentModel = firstNonEmpty(currentModel, strings.TrimSpace(threadInfo.Model))
currentEffort = firstNonEmpty(currentEffort, strings.TrimSpace(threadInfo.ReasoningEffort))
}
}
currentModel = firstNonEmpty(currentModel, codexAppServerDefaultModel(models))
settings.Model = currentModel
settings.ReasoningEffort = appServerReasoningEffortForModel(models, currentModel, currentEffort)
return settings
}
// appServerSupportedEfforts keeps field presence separate from parsed values.
// An advertised empty list means the selected model has no reasoning control;
// a missing or malformed field falls back to compatibility options.
func appServerSupportedEfforts(model map[string]any) ([]string, bool) {
raw, advertised := model["supportedReasoningEfforts"].([]any)
if !advertised {
return nil, false
}
out := make([]string, 0, len(raw))
for _, entry := range raw {
switch typed := entry.(type) {
case string:
if trimmed := strings.TrimSpace(typed); trimmed != "" {
out = append(out, trimmed)
}
case map[string]any:
if value := firstNonEmpty(asString(typed["reasoningEffort"]), asString(typed["effort"]), asString(typed["value"])); value != "" {
out = append(out, value)
}
}
}
return dedupeStrings(out), true
}
func appServerReasoningEffortForModel(
models []map[string]any,
modelID string,
selected string,
) string {
modelID = strings.TrimSpace(modelID)
rawSelected := strings.TrimSpace(selected)
selected = codexAppServerReasoningEffortValue(rawSelected)
for _, model := range models {
candidateID := codexAppServerModelValue(model)
if candidateID != modelID {
continue
}
efforts, advertised := appServerSupportedEfforts(model)
if !advertised {
return selected
}
if len(efforts) == 0 {
return ""
}
for _, effort := range efforts {
if effort == rawSelected || effort == selected {
return effort
}
}
rawAdvertisedDefault := asString(model["defaultReasoningEffort"])
advertisedDefault := codexAppServerReasoningEffortValue(rawAdvertisedDefault)
for _, effort := range efforts {
if effort == rawAdvertisedDefault || effort == advertisedDefault {
return effort
}
}
return efforts[0]
}
return selected
}
// codexAppServerSessionSettingsWithConfig keeps live empty values meaningful.
// The shared ACP projection intentionally ignores empty config values, while
// Codex uses them as tombstones when a setting is cleared or the selected
// model explicitly advertises no reasoning efforts.
func codexAppServerSessionSettingsWithConfig(
base *SessionSettings,
provider string,
defaultPermissionModeID string,
config map[string]any,
) *SessionSettings {
settings := sessionSettingsWithACPConfig(base, provider, defaultPermissionModeID, config, true)
_, hasModel := config["model"]
_, hasReasoning := config["reasoning_effort"]
_, hasSpeed := config["service_tier"]
if !hasModel && !hasReasoning && !hasSpeed {
return settings
}
if settings == nil {
normalized := normalizeSessionSettings(base, provider, defaultPermissionModeID)
settings = &normalized
}
if hasModel {
settings.Model = asString(config["model"])
}
if hasReasoning {
settings.ReasoningEffort = asString(config["reasoning_effort"])
}
if hasSpeed {
settings.Speed = asString(config["service_tier"])
}
return settings
}
func (a *CodexAppServerAdapter) ApplySessionSettings(
_ context.Context,
session Session,
patch SessionSettingsPatch,
) error {
// Model and reasoning effort are applied as per-turn overrides on the next
// turn/start; no live RPC is required. Mirror the values into the config
// option state so pickers stay in sync.
a.mu.Lock()
defer a.mu.Unlock()
appSession := a.sessions[strings.TrimSpace(session.AgentSessionID)]
if appSession == nil {
slog.Warn("agent session app-server settings apply skipped without live session",
"event", "agent_session.app_server.settings.apply_skipped",
"provider", a.config.provider,
"room_id", session.RoomID,
"agent_session_id", session.AgentSessionID,
"session_model", session.SettingsValue().Model,
"patch_has_model", patch.Model != nil,
)
return nil
}
appSession.ensureInitialized()
refreshedModelReasoningOptions := false
if len(appSession.models) > 0 && (patch.Model != nil || patch.ReasoningEffort != nil) {
effectiveSession := session
effectiveSettings := effectiveSession.SettingsValue()
if patch.Model != nil {
effectiveSettings.Model = strings.TrimSpace(*patch.Model)
}
if patch.ReasoningEffort != nil {
effectiveSettings.ReasoningEffort = strings.TrimSpace(*patch.ReasoningEffort)
}
if patch.Speed != nil {
effectiveSettings.Speed = strings.TrimSpace(*patch.Speed)
}
effectiveSession.Settings = &effectiveSettings
effectiveSettings = codexAppServerEffectiveSettings(appSession.models, effectiveSession, nil)
effectiveSession.Settings = &effectiveSettings
applyACPConfigOptionDescriptors(
&appSession.acpLiveState,
codexAppServerConfigOptionDescriptors(appSession.models, effectiveSession, nil),
)
refreshedModelReasoningOptions = true
}
if patch.Model != nil && !refreshedModelReasoningOptions {
model := strings.TrimSpace(*patch.Model)
appSession.configOptions["model"] = model
updateConfigOptionDescriptorValue(appSession.configOptionDescriptors, "model", model)
}
if patch.ReasoningEffort != nil && !refreshedModelReasoningOptions {
reasoning := codexAppServerReasoningEffortValue(*patch.ReasoningEffort)
appSession.configOptions["reasoning_effort"] = reasoning
updateConfigOptionDescriptorValue(appSession.configOptionDescriptors, "reasoning_effort", reasoning)
}
if patch.Speed != nil {
// Speed (service_tier) is applied as a config override on the next
// thread/start; mirror it into the picker state so the dropdown stays
// in sync. "standard" clears the override.
speed := strings.TrimSpace(*patch.Speed)
appSession.configOptions["service_tier"] = speed
updateConfigOptionDescriptorValue(appSession.configOptionDescriptors, "service_tier", speed)
}
slog.Info("agent session app-server settings applied",
"event", "agent_session.app_server.settings.applied",
"provider", a.config.provider,
"room_id", session.RoomID,
"agent_session_id", session.AgentSessionID,
"session_model", session.SettingsValue().Model,
"patch_has_model", patch.Model != nil,
"patch_model", stringPtrLogValue(patch.Model),
"config_option_model", asString(appSession.configOptions["model"]),
)
return nil
}
func stringPtrLogValue(value *string) string {
if value == nil {
return ""
}
return strings.TrimSpace(*value)
}
func (*CodexAppServerAdapter) RequiresNewSessionForSettings(Session, SessionSettingsPatch) bool {
// The app-server supports per-turn model/effort overrides, so settings
// changes never require recreating the session.
return false
}
func (a *CodexAppServerAdapter) snapshotExecState(agentSessionID string) (codexAppServerExecState, bool) {
if a == nil {
return codexAppServerExecState{}, false
}
a.mu.Lock()
defer a.mu.Unlock()
appSession := a.sessions[strings.TrimSpace(agentSessionID)]
if appSession == nil || appSession.client == nil {
return codexAppServerExecState{}, false
}
return codexAppServerExecState{
liveSession: appSession,
models: cloneCodexAppServerModels(appSession.models),
config: clonePayloadDeep(appSession.configOptions),
defaultModel: appSession.defaultModel,
}, true
}