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
472 lines
16 KiB
Go
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
|
|
}
|