源码快照 @ 2026-09-01 (source-only, 剔除非源码素材)
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
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
This commit is contained in:
@@ -0,0 +1,471 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user