diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 566714fc2..0e1620308 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1934,6 +1934,28 @@ replaced; do not carry obsolete compatibility code forward to satisfy this secti matching retries return committed state without replay. No Turn-level overrides, provider catalog or self-hosted/none credential expansion is included. Public input/null semantics and examples live in `contracts/agents-api/model-execution.md`. +- Session execution-configuration reads use a separate immutable safe projection, + written with provenance in the same creation transaction as Session resources. + Read no credential ciphertext and never recompute sources from current Agents + or operator defaults. Model/harness sources are independent; provider bundles + retain one source. Project reads redact all deployment provider details. Old + Sessions expose persisted model/harness with unknown sources and unavailable + provider metadata, without backfill. Projection metadata does not alter retry + identity; retries cannot replace it. Keep this query separate from runtime + observations and do not touch activity or wake sandboxes. The versioned contract + is `contracts/agents-api/execution-configuration.md`. +- Provider configuration support belongs to shared adapter-owned declarations in + `internal/harnessconfig`. One immutable declaration registry supplies public + provider validation, startup discovery and the optional safe provider descriptor + on native adapter registration. The separate engine catalog qualifies operations, + not a duplicate table of provider facts. Core owns credential environment and + endpoint admission policy, deployment enablement and safe HTTP projection. No parallel + HTTP harness table or readiness gate is permitted. The startup query includes + these declarations only with `include=configuration_capabilities`, preserving + its legacy default response for installed strict clients; the extension has an + independent schema version. Advertise build scope and unknown live availability, + not universal support across deployed Runtime versions. Operation-specific + admission remains authoritative; an extra registry declaration cannot enable it. - Public Agent updates use `POST /v1/agents/{agent_id}` with the same tenant/Beta boundary and shared saved-field validation. Preserve omission separately from null; only supplied fields replace saved values. Metadata is a separate whole-map diff --git a/apps/parsar-daemon/internal/agent/provider_configuration.go b/apps/parsar-daemon/internal/agent/provider_configuration.go new file mode 100644 index 000000000..25ddbaba3 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/provider_configuration.go @@ -0,0 +1,24 @@ +package agent + +import ( + "cmp" + "slices" + + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" +) + +func providerConfigurationDescriptor(registry harnessconfig.Registry, kind string) *proto.AgentProviderConfiguration { + configuration, known := registry.Lookup(kind) + if !known || len(configuration.Providers) == 0 { + return nil + } + descriptor := &proto.AgentProviderConfiguration{SchemaVersion: 1, Providers: make([]proto.AgentProviderProtocol, 0, len(configuration.Providers))} + for _, provider := range configuration.Providers { + descriptor.Providers = append(descriptor.Providers, proto.AgentProviderProtocol{ + Protocol: provider.Protocol, RequiresTokenLimits: provider.RequiresTokenLimits, + }) + } + slices.SortFunc(descriptor.Providers, func(a, b proto.AgentProviderProtocol) int { return cmp.Compare(a.Protocol, b.Protocol) }) + return descriptor +} diff --git a/apps/parsar-daemon/internal/agent/provider_configuration_test.go b/apps/parsar-daemon/internal/agent/provider_configuration_test.go new file mode 100644 index 000000000..766423783 --- /dev/null +++ b/apps/parsar-daemon/internal/agent/provider_configuration_test.go @@ -0,0 +1,84 @@ +package agent_test + +import ( + "encoding/json" + "reflect" + "strings" + "testing" + + "github.com/MiniMax-AI-Dev/parsar/apps/parsar-daemon/internal/agent" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" +) + +func TestRegisteredAdaptersShareProviderDeclarations(t *testing.T) { + registry := agent.NewRegistry() + for _, kind := range builtin.Registry().Kinds() { + registry.RegisterKind(proto.SupportedAgentKind{Kind: kind, Available: false, Version: "fixture"}, stubFactory(kind)) + } + for _, kind := range registry.SupportedAgentKinds() { + declaration, _ := builtin.Registry().Lookup(kind.Kind) + descriptor := kind.ProviderConfiguration + if kind.Available || descriptor == nil || descriptor.SchemaVersion != 1 || len(descriptor.Providers) != len(declaration.Providers) { + t.Fatalf("declaration changed readiness or was omitted: %#v", kind) + } + for _, provider := range descriptor.Providers { + rule, ok := declaration.Provider(provider.Protocol) + if !ok || rule.RequiresTokenLimits != provider.RequiresTokenLimits { + t.Fatalf("adapter registration disagrees with Core admission: %#v", provider) + } + } + } +} + +func TestProviderDescriptorIsSafeDefensiveAndWireCompatible(t *testing.T) { + configuration := harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: "fixture", RequiresTokenLimits: true}}} + declarations := harnessconfig.NewRegistry(map[string]harnessconfig.Configuration{"additional-adapter": configuration}) + registry := agent.NewRegistryWithConfigurations(declarations) + registry.RegisterKind(proto.SupportedAgentKind{ + Kind: "additional-adapter", Available: true, + ProviderConfiguration: &proto.AgentProviderConfiguration{SchemaVersion: 99, Providers: []proto.AgentProviderProtocol{{Protocol: "private-canary"}}}, + }, stubFactory("additional-adapter")) + configuration.Providers[0].Protocol = "private-canary" + first := registry.SupportedAgentKinds() + first[0].ProviderConfiguration.Providers[0].Protocol = "private-canary" + first[0].ProviderConfiguration.SchemaVersion = 99 + next := registry.SupportedAgentKinds() + if next[0].ProviderConfiguration.SchemaVersion != 1 || next[0].ProviderConfiguration.Providers[0].Protocol != "fixture" { + t.Fatal("caller mutated the adapter descriptor") + } + envelope, err := proto.NewEnvelope(proto.TypeHeartbeat, "", proto.HeartbeatPayload{SupportedAgentKinds: next}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(envelope.Payload), "private-canary") { + t.Fatal("unregistered configuration leaked") + } + var roundTrip proto.HeartbeatPayload + if err := envelope.DecodePayload(&roundTrip); err != nil || !reflect.DeepEqual(roundTrip.SupportedAgentKinds, next) { + t.Fatalf("descriptor round trip failed: %v", err) + } + // The existing envelope decoder ignores added JSON fields; a peer built + // before this optional descriptor therefore still accepts the heartbeat. + var oldPeer struct { + Kinds []struct { + Kind string `json:"kind"` + Available bool `json:"available"` + Capabilities proto.AgentKindCapabilities `json:"capabilities"` + } `json:"supported_agent_kinds"` + } + if err := envelope.DecodePayload(&oldPeer); err != nil || len(oldPeer.Kinds) != 1 || oldPeer.Kinds[0].Kind != "additional-adapter" || !oldPeer.Kinds[0].Available { + t.Fatalf("older peer rejected the optional declaration: %v", err) + } + var oldDescriptor proto.SupportedAgentKind + if json.Unmarshal([]byte(`{"kind":"additional-adapter","available":true}`), &oldDescriptor) != nil || oldDescriptor.ProviderConfiguration != nil { + t.Fatal("missing declaration must remain unknown") + } + registry.Register("undeclared", stubFactory("undeclared")) + for _, kind := range registry.SupportedAgentKinds() { + if kind.Kind == "undeclared" && kind.ProviderConfiguration != nil { + t.Fatal("unknown adapter inherited support") + } + } +} diff --git a/apps/parsar-daemon/internal/agent/registry.go b/apps/parsar-daemon/internal/agent/registry.go index d9f6459f5..176e5acf4 100644 --- a/apps/parsar-daemon/internal/agent/registry.go +++ b/apps/parsar-daemon/internal/agent/registry.go @@ -24,6 +24,8 @@ import ( "sync" "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/proto" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" ) // Factory builds a Session for one prompt_request. out is the upstream @@ -55,17 +57,25 @@ var ErrUnsupportedKind = errors.New("agent: unsupported agent_kind") // Registry maps agent_kind → Factory and keeps the daemon-advertised // capability descriptor for each kind. Safe for concurrent use. type Registry struct { - mu sync.RWMutex - factories map[string]Factory - preparers map[string]PreparationFactory - kinds map[string]proto.SupportedAgentKind + mu sync.RWMutex + factories map[string]Factory + preparers map[string]PreparationFactory + kinds map[string]proto.SupportedAgentKind + configurations harnessconfig.Registry } func NewRegistry() *Registry { + return NewRegistryWithConfigurations(builtin.Registry()) +} + +// NewRegistryWithConfigurations shares an immutable adapter declaration snapshot +// with discovery and validation; factory registration still owns availability. +func NewRegistryWithConfigurations(configurations harnessconfig.Registry) *Registry { return &Registry{ - factories: make(map[string]Factory), - preparers: make(map[string]PreparationFactory), - kinds: make(map[string]proto.SupportedAgentKind), + factories: make(map[string]Factory), + preparers: make(map[string]PreparationFactory), + kinds: make(map[string]proto.SupportedAgentKind), + configurations: configurations, } } @@ -92,6 +102,7 @@ func (r *Registry) RegisterKind(info proto.SupportedAgentKind, f Factory) { delete(r.preparers, kind) info.Capabilities.Preparation = false info.Capabilities.WorkspaceReadPreparation = false + info.ProviderConfiguration = providerConfigurationDescriptor(r.configurations, kind) r.kinds[kind] = info } @@ -129,6 +140,7 @@ func (r *Registry) SupportedAgentKinds() []proto.SupportedAgentKind { if info.Kind == "" { info = proto.SupportedAgentKind{Kind: kind, Available: true} } + info.ProviderConfiguration = info.ProviderConfiguration.Clone() out = append(out, info) } slices.SortFunc(out, func(a, b proto.SupportedAgentKind) int { diff --git a/apps/parsar-daemon/internal/cli/claude_sdk_test.go b/apps/parsar-daemon/internal/cli/claude_sdk_test.go index 030d6c80f..e576fe51c 100644 --- a/apps/parsar-daemon/internal/cli/claude_sdk_test.go +++ b/apps/parsar-daemon/internal/cli/claude_sdk_test.go @@ -82,6 +82,13 @@ func TestClaudeSDKDiscoveryAndRegistration(t *testing.T) { t.Fatal(info) } for _, registered := range reg.SupportedAgentKinds() { + if registered.Kind == "claude_sdk" { + configuration := registered.ProviderConfiguration + if configuration == nil || configuration.SchemaVersion != 1 || len(configuration.Providers) != 1 || configuration.Providers[0].Protocol != "anthropic" || configuration.Providers[0].RequiresTokenLimits { + t.Fatal("SDK registration lost its provider declaration") + } + registered.ProviderConfiguration = nil + } if registered.Kind == "claude_sdk" && registered != info { t.Fatalf("SDK descriptor changed: %+v", registered) } diff --git a/contracts/agents-api/execution-configuration.md b/contracts/agents-api/execution-configuration.md new file mode 100644 index 000000000..fc8c1c6ca --- /dev/null +++ b/contracts/agents-api/execution-configuration.md @@ -0,0 +1,92 @@ +# Execution configuration queries + +These read-only Core extensions describe configuration, not execution health. +They require the existing project bearer authentication and `OpenAI-Beta: +agents=v1`. They never contact a model provider, start a Turn or wake a sandbox. + +## Frozen Session selections + +`GET /v1/agents/sessions/{session_id}/execution-configuration` returns: + +```json +{ + "object": "agent.session.execution_configuration", + "schema_version": 1, + "session_id": "013773a9-44b9-4f84-baca-b51c04a01201", + "model": {"value": "requested-model", "source": "session"}, + "harness": {"value": "codex", "source": "agent"}, + "model_provider": { + "source": "agent", + "status": "available", + "configuration": { + "protocol": "responses", + "base_url": "https://model.example/v1", + "api_key_configured": true + } + } +} +``` + +`source` is `session`, `agent`, `deployment` or `unknown`. Model and harness +sources are independent; a model-only override can retain an Agent's harness and +whole provider bundle. Explicit inline harness selection is `session`; a null +inline Agent extension resets the harness to `deployment` without clearing the +inherited provider. A null Session provider inherits normally. Model is required +inline or inherited from a saved Agent; it has no implicit deployment default. + +The provider's `status` describes visibility: + +- `available`: a safe Session- or Agent-supplied bundle. Configuration contains + protocol, endpoint, configured-key flag and optional token limits. +- `redacted`: deployment-owned configuration. Source is `deployment` and + configuration is null. Project bearer credentials do not grant deployment + secret access; this API has no endpoint-reveal override. +- `unavailable`: no trustworthy safe provider projection was recorded. Source is + `unknown` and configuration is null. This does not mean that execution failed. + +New public Session creations save a minimal safe projection and provenance in the +same transaction as the Session and its private credential snapshot. Reads select +only that safe row and immutable Session configuration, without decrypting secrets +or re-resolving an Agent or deployment defaults. Agent edits/deletion, deployment +changes, Core restart and suspend/resume cannot change the view. Same-key retries +return the original Session and do not repair or overwrite its provenance. + +Historical Sessions without the projection report their persisted model/harness +when present, with `unknown` sources; a missing value is null. Their provider view +is `unavailable`, even if a private credential row exists. No backfill guesses +provenance. Missing, deleted and foreign-tenant Session IDs share the existing +not-found response. Responses are `Cache-Control: no-store`. Keys, ciphertext, +secret references, native headers, query parameters and permissions are excluded. + +## Supported provider configuration + +`GET /v1/agents/core/startup-configuration?include=configuration_capabilities` +adds a `configuration_capabilities` field to the existing startup view. Without +that exact optional query, the legacy response shape is unchanged, including its +schema version. Duplicate, unknown or malformed startup queries are rejected. +The extension has its own `schema_version: 1`: + +- `scope: core_build_provider_configuration` identifies the limited subject. +- `runtime_availability: unknown` explicitly excludes live peer readiness. +- `admission` declares Core's hosted-only credential boundary, HTTPS endpoint + restrictions and nonnegative ordered token limits. +- `harnesses` is sorted by registered harness name. Each entry contains + `support` (`supported` or `unknown`), deployment `enabled` and `default` flags, + plus provider protocol declarations. An unknown declaration has no providers. +- Each provider lists `required_fields` and `positive_fields`. Codex declares + Responses; Claude SDK and MiniMax Code declare Anthropic. MiniMax Code also + requires positive `context_window` and `max_output_tokens`. + +Adapter-owned declarations in `internal/harnessconfig` have one immutable registry +used by provider validation and discovery. Native adapter registration attaches +the same optional safe provider descriptor to the common Runtime contract. The +separate engine catalog still owns qualification of public operations. Core +composition adds deployment enablement and Core-owned admission restrictions, then projects +only public fields. It does not serialize native capability or operator objects. +Adding a declaration does not authorize a new public operation. Runtime versions +may differ; actual selection/admission still checks the chosen Runtime's features. +No node, binary, endpoint, model or key readiness is implied by support or enablement. + +The endpoints do not mutate configuration, rotate keys, migrate Sessions, expose +a model catalog or accept arbitrary native options. See +[model-execution.md](model-execution.md) for write and inheritance semantics. diff --git a/contracts/agents-api/openapi.yaml b/contracts/agents-api/openapi.yaml index 0bdb19524..9dfc10217 100644 --- a/contracts/agents-api/openapi.yaml +++ b/contracts/agents-api/openapi.yaml @@ -128,6 +128,33 @@ definitions: required: - harness type: object + v1.CoreConfigurationCapabilities: + properties: + admission: + $ref: '#/definitions/v1.CoreModelProviderAdmission' + harnesses: + items: + $ref: '#/definitions/v1.CoreHarnessConfigurationSupport' + type: array + runtime_availability: + enum: + - unknown + type: string + schema_version: + enum: + - 1 + type: integer + scope: + enum: + - core_build_provider_configuration + type: string + required: + - admission + - harnesses + - runtime_availability + - schema_version + - scope + type: object v1.CoreConfiguredStartupConfiguration: properties: daemon_gateway: @@ -162,6 +189,30 @@ definitions: - model_providers - self_hosted type: object + v1.CoreHarnessConfigurationSupport: + properties: + default: + type: boolean + enabled: + type: boolean + harness: + type: string + providers: + items: + $ref: '#/definitions/v1.CoreProviderProtocolSupport' + type: array + support: + enum: + - supported + - unknown + type: string + required: + - default + - enabled + - harness + - providers + - support + type: object v1.CoreHarnessModelProviderConfiguration: properties: endpoint_configured: @@ -193,8 +244,83 @@ definitions: - maintenance - provider type: object + v1.CoreModelProviderAdmission: + properties: + base_url: + $ref: '#/definitions/v1.CoreProviderEndpointRules' + credential_environment_types: + items: + enum: + - openai_hosted + type: string + type: array + token_limits: + $ref: '#/definitions/v1.CoreProviderTokenLimitRules' + required: + - base_url + - credential_environment_types + - token_limits + type: object + v1.CoreProviderEndpointRules: + properties: + fragment: + type: boolean + query: + type: boolean + schemes: + items: + enum: + - https + type: string + type: array + user_info: + type: boolean + required: + - fragment + - query + - schemes + - user_info + type: object + v1.CoreProviderProtocolSupport: + properties: + positive_fields: + items: + enum: + - context_window + - max_output_tokens + type: string + type: array + protocol: + type: string + required_fields: + items: + enum: + - protocol + - base_url + - api_key + - context_window + - max_output_tokens + type: string + type: array + required: + - positive_fields + - protocol + - required_fields + type: object + v1.CoreProviderTokenLimitRules: + properties: + max_output_not_above_context: + type: boolean + minimum: + type: integer + required: + - max_output_not_above_context + - minimum + type: object v1.CoreStartupConfiguration: properties: + configuration_capabilities: + $ref: '#/definitions/v1.CoreConfigurationCapabilities' configured: $ref: '#/definitions/v1.CoreConfiguredStartupConfiguration' object: @@ -832,6 +958,46 @@ definitions: required: - error type: object + v1.ExecutionProviderSelection: + properties: + configuration: + allOf: + - $ref: '#/definitions/v1.ModelProviderView' + x-nullable: true + source: + enum: + - session + - agent + - deployment + - unknown + type: string + status: + enum: + - available + - redacted + - unavailable + type: string + required: + - configuration + - source + - status + type: object + v1.ExecutionSelection: + properties: + source: + enum: + - session + - agent + - deployment + - unknown + type: string + value: + type: string + x-nullable: true + required: + - source + - value + type: object v1.InlineAgent: properties: instructions: @@ -2135,6 +2301,32 @@ definitions: - event_id - type type: object + v1.SessionExecutionConfiguration: + properties: + harness: + $ref: '#/definitions/v1.ExecutionSelection' + model: + $ref: '#/definitions/v1.ExecutionSelection' + model_provider: + $ref: '#/definitions/v1.ExecutionProviderSelection' + object: + enum: + - agent.session.execution_configuration + type: string + schema_version: + enum: + - 1 + type: integer + session_id: + type: string + required: + - harness + - model + - model_provider + - object + - schema_version + - session_id + type: object v1.SessionExecutionInput: properties: model_provider: @@ -3074,6 +3266,12 @@ paths: name: OpenAI-Beta required: true type: string + - description: Opt in to versioned provider configuration capabilities + enum: + - configuration_capabilities + in: query + name: include + type: string produces: - application/json responses: @@ -4418,6 +4616,56 @@ paths: summary: Submit Session input events tags: - Sessions + /agents/sessions/{session_id}/execution-configuration: + get: + description: Returns the committed model, harness and safe provider selection + with recorded sources. This read never decrypts credentials, resolves current + defaults or probes execution health. Deployment provider details remain redacted + for project callers. Historical provenance and missing provider projections + are explicitly unknown/unavailable. + parameters: + - description: agents=v1 + in: header + name: OpenAI-Beta + required: true + type: string + - description: Session ID + in: path + name: session_id + required: true + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/v1.SessionExecutionConfiguration' + "400": + description: Bad Request + schema: + $ref: '#/definitions/v1.ErrorResponse' + "401": + description: Unauthorized + schema: + $ref: '#/definitions/v1.ErrorResponse' + "404": + description: Not Found + schema: + $ref: '#/definitions/v1.ErrorResponse' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/v1.ErrorResponse' + "503": + description: Service Unavailable + schema: + $ref: '#/definitions/v1.ErrorResponse' + security: + - BearerAuth: [] + summary: Retrieve frozen Session execution configuration + tags: + - Core extensions /agents/sessions/{session_id}/items: get: description: Returns supported message and tool Items in first-observation order. diff --git a/contracts/agents-api/v1/configuration_capabilities.go b/contracts/agents-api/v1/configuration_capabilities.go new file mode 100644 index 000000000..f104eb332 --- /dev/null +++ b/contracts/agents-api/v1/configuration_capabilities.go @@ -0,0 +1,63 @@ +package v1 + +// CoreConfigurationCapabilities is the safe provider-configuration subset of +// this Core build's adapter declarations. It does not describe all Agent options +// or guarantee support across deployed Runtime versions. +type CoreConfigurationCapabilities struct { + SchemaVersion int `json:"schema_version" binding:"required" enums:"1"` + Scope string `json:"scope" binding:"required" enums:"core_build_provider_configuration"` + RuntimeAvailability string `json:"runtime_availability" binding:"required" enums:"unknown"` + Admission CoreModelProviderAdmission `json:"admission" binding:"required"` + Harnesses []CoreHarnessConfigurationSupport `json:"harnesses" binding:"required"` +} + +type CoreHarnessConfigurationSupport struct { + Harness string `json:"harness" binding:"required"` + Support string `json:"support" binding:"required" enums:"supported,unknown"` + Enabled bool `json:"enabled" binding:"required"` + Default bool `json:"default" binding:"required"` + Providers []CoreProviderProtocolSupport `json:"providers" binding:"required"` +} + +type CoreProviderProtocolSupport struct { + Protocol string `json:"protocol" binding:"required"` + RequiredFields []string `json:"required_fields" binding:"required" enums:"protocol,base_url,api_key,context_window,max_output_tokens"` + PositiveFields []string `json:"positive_fields" binding:"required" enums:"context_window,max_output_tokens"` +} + +type CoreModelProviderAdmission struct { + CredentialEnvironmentTypes []string `json:"credential_environment_types" binding:"required" enums:"openai_hosted"` + BaseURL CoreProviderEndpointRules `json:"base_url" binding:"required"` + TokenLimits CoreProviderTokenLimitRules `json:"token_limits" binding:"required"` +} + +type CoreProviderEndpointRules struct { + Schemes []string `json:"schemes" binding:"required" enums:"https"` + UserInfo bool `json:"user_info" binding:"required"` + Query bool `json:"query" binding:"required"` + Fragment bool `json:"fragment" binding:"required"` +} + +type CoreProviderTokenLimitRules struct { + Minimum int32 `json:"minimum" binding:"required"` + MaxOutputNotAboveContext bool `json:"max_output_not_above_context" binding:"required"` +} + +func (c *CoreConfigurationCapabilities) Clone() *CoreConfigurationCapabilities { + if c == nil { + return nil + } + copy := *c + copy.Admission.CredentialEnvironmentTypes = append([]string{}, c.Admission.CredentialEnvironmentTypes...) + copy.Admission.BaseURL.Schemes = append([]string{}, c.Admission.BaseURL.Schemes...) + copy.Harnesses = append([]CoreHarnessConfigurationSupport{}, c.Harnesses...) + for i := range copy.Harnesses { + copy.Harnesses[i].Providers = append([]CoreProviderProtocolSupport{}, c.Harnesses[i].Providers...) + for j := range copy.Harnesses[i].Providers { + provider := ©.Harnesses[i].Providers[j] + provider.RequiredFields = append([]string{}, provider.RequiredFields...) + provider.PositiveFields = append([]string{}, provider.PositiveFields...) + } + } + return © +} diff --git a/contracts/agents-api/v1/model_execution.go b/contracts/agents-api/v1/model_execution.go index 7731dcdf2..451a69559 100644 --- a/contracts/agents-api/v1/model_execution.go +++ b/contracts/agents-api/v1/model_execution.go @@ -2,8 +2,10 @@ package v1 import ( "errors" - "net/url" "strings" + + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" ) // SessionExecutionInput is a write-only execution extension, not a provider resource. @@ -21,14 +23,17 @@ type ModelProviderInput struct { } func (p *ModelProviderInput) Validate() error { + return p.validate(builtin.Registry()) +} + +func (p *ModelProviderInput) validate(registry harnessconfig.Registry) error { if p == nil { return errors.New("model_provider is required") } - u, err := url.Parse(p.BaseURL) - if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.ContainsAny(p.BaseURL, "\x00\r\n") { + if !validModelProviderBaseURL(p.BaseURL) { return errors.New("model provider requires an HTTPS base_url without credentials, query or fragment") } - if p.Protocol != "anthropic" && p.Protocol != "responses" { + if !registry.SupportsProtocol(p.Protocol) { return errors.New("unsupported model provider protocol") } if strings.TrimSpace(p.APIKey) == "" || len(p.APIKey) > 16384 || strings.ContainsAny(p.APIKey, "\x00\r\n") { @@ -41,15 +46,18 @@ func (p *ModelProviderInput) Validate() error { } func (p *ModelProviderInput) ValidateHarness(harness string) error { - if err := p.Validate(); err != nil { + return p.ValidateHarnessWithRegistry(harness, builtin.Registry()) +} + +// ValidateHarnessWithRegistry uses the same adapter registration snapshot as +// capability discovery. It does not enable an execution engine or placement. +func (p *ModelProviderInput) ValidateHarnessWithRegistry(harness string, registry harnessconfig.Registry) error { + if err := p.validate(registry); err != nil { return err } - return p.SafeView().ValidateHarness(harness) + return p.SafeView().ValidateHarnessWithRegistry(harness, registry) } func ValidateModelProtocol(protocol, harness string) error { - if (harness == "codex" && protocol == "responses") || ((harness == "claude_sdk" || harness == "mcode") && protocol == "anthropic") { - return nil - } - return errors.New("selected harness does not support this model provider protocol") + return builtin.Registry().ValidateProtocol(harness, protocol) } diff --git a/contracts/agents-api/v1/model_provider_admission.go b/contracts/agents-api/v1/model_provider_admission.go new file mode 100644 index 000000000..c9e12f352 --- /dev/null +++ b/contracts/agents-api/v1/model_provider_admission.go @@ -0,0 +1,28 @@ +package v1 + +import ( + "net/url" + "strings" +) + +const providerEnvironment = "openai_hosted" +const providerScheme = "https" + +func ModelProviderEnvironmentSupported(environment string) bool { + return environment == providerEnvironment +} + +// ModelProviderConfigurationAdmission reports Core policy, independently of the +// adapter declaration and deployment enablement. +func ModelProviderConfigurationAdmission() CoreModelProviderAdmission { + return CoreModelProviderAdmission{ + CredentialEnvironmentTypes: []string{providerEnvironment}, + BaseURL: CoreProviderEndpointRules{Schemes: []string{providerScheme}}, + TokenLimits: CoreProviderTokenLimitRules{Minimum: 0, MaxOutputNotAboveContext: true}, + } +} + +func validModelProviderBaseURL(base string) bool { + u, err := url.Parse(base) + return err == nil && u.Scheme == providerScheme && u.Hostname() != "" && u.User == nil && u.RawQuery == "" && u.Fragment == "" && !strings.ContainsAny(base, "\x00\r\n") +} diff --git a/contracts/agents-api/v1/saved_core_extension.go b/contracts/agents-api/v1/saved_core_extension.go index 72b8651db..247fe3f9b 100644 --- a/contracts/agents-api/v1/saved_core_extension.go +++ b/contracts/agents-api/v1/saved_core_extension.go @@ -1,6 +1,11 @@ package v1 -import "errors" +import ( + "errors" + + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" +) // SavedAgentCoreInput carries defaults for future Sessions. The provider bundle // is replaced as a whole; its API key is write-only. @@ -60,14 +65,12 @@ func (p *ModelProviderInput) SafeView() *ModelProviderView { // ValidateHarness checks non-confidential protocol and limit compatibility. func (p *ModelProviderView) ValidateHarness(harness string) error { + return p.ValidateHarnessWithRegistry(harness, builtin.Registry()) +} + +func (p *ModelProviderView) ValidateHarnessWithRegistry(harness string, registry harnessconfig.Registry) error { if p == nil { return errors.New("model_provider is required") } - if err := ValidateModelProtocol(p.Protocol, harness); err != nil { - return err - } - if harness == "mcode" && (p.ContextWindow == 0 || p.MaxOutputTokens == 0) { - return errors.New("MiniMax Code requires model context_window and max_output_tokens") - } - return nil + return registry.Validate(harness, p.Protocol, p.ContextWindow, p.MaxOutputTokens) } diff --git a/contracts/agents-api/v1/session_execution_configuration.go b/contracts/agents-api/v1/session_execution_configuration.go new file mode 100644 index 000000000..e85a73e7e --- /dev/null +++ b/contracts/agents-api/v1/session_execution_configuration.go @@ -0,0 +1,25 @@ +package v1 + +// SessionExecutionConfiguration describes committed configuration, not live +// execution health. Unknown provenance is never reconstructed from defaults. +type SessionExecutionConfiguration struct { + Object string `json:"object" binding:"required" enums:"agent.session.execution_configuration"` + SchemaVersion int `json:"schema_version" binding:"required" enums:"1"` + SessionID string `json:"session_id" binding:"required"` + Model ExecutionSelection `json:"model" binding:"required"` + Harness ExecutionSelection `json:"harness" binding:"required"` + ModelProvider ExecutionProviderSelection `json:"model_provider" binding:"required"` +} + +type ExecutionSelection struct { + Value *string `json:"value" binding:"required" extensions:"x-nullable"` + Source string `json:"source" binding:"required" enums:"session,agent,deployment,unknown"` +} + +// Deployment credentials have no public endpoint projection. Unavailable means +// no trustworthy safe provider snapshot was recorded, not failed readiness. +type ExecutionProviderSelection struct { + Source string `json:"source" binding:"required" enums:"session,agent,deployment,unknown"` + Status string `json:"status" binding:"required" enums:"available,redacted,unavailable"` + Configuration *ModelProviderView `json:"configuration" binding:"required" extensions:"x-nullable"` +} diff --git a/contracts/agents-api/v1/startup_configuration.go b/contracts/agents-api/v1/startup_configuration.go index 6036bbbc4..3fb8faf12 100644 --- a/contracts/agents-api/v1/startup_configuration.go +++ b/contracts/agents-api/v1/startup_configuration.go @@ -3,10 +3,11 @@ package v1 // CoreStartupConfiguration is a secret-free snapshot of the validated process // configuration. It never describes a Session, Environment or live Runtime. type CoreStartupConfiguration struct { - Object string `json:"object" binding:"required" enums:"agents.core.startup_configuration"` - SchemaVersion int `json:"schema_version" binding:"required" enums:"1"` - Supported CoreSupportedConfiguration `json:"supported" binding:"required"` - Configured CoreConfiguredStartupConfiguration `json:"configured" binding:"required"` + Object string `json:"object" binding:"required" enums:"agents.core.startup_configuration"` + SchemaVersion int `json:"schema_version" binding:"required" enums:"1"` + Supported CoreSupportedConfiguration `json:"supported" binding:"required"` + Configured CoreConfiguredStartupConfiguration `json:"configured" binding:"required"` + ConfigurationCapabilities *CoreConfigurationCapabilities `json:"configuration_capabilities,omitempty"` } type CoreSupportedConfiguration struct { diff --git a/internal/agentdaemon/proto/inbound.go b/internal/agentdaemon/proto/inbound.go index 1b2770ee0..c62b37896 100644 --- a/internal/agentdaemon/proto/inbound.go +++ b/internal/agentdaemon/proto/inbound.go @@ -294,10 +294,11 @@ type AgentKindCapabilities struct { // can report unavailable kinds with Available=false when the adapter // exists but the underlying CLI binary is missing. type SupportedAgentKind struct { - Kind string `json:"kind"` - Available bool `json:"available"` - Version string `json:"version,omitempty"` - Capabilities AgentKindCapabilities `json:"capabilities,omitempty"` + Kind string `json:"kind"` + Available bool `json:"available"` + Version string `json:"version,omitempty"` + Capabilities AgentKindCapabilities `json:"capabilities,omitempty"` + ProviderConfiguration *AgentProviderConfiguration `json:"provider_configuration,omitempty"` } // HeartbeatPayload is the daemon's liveness ping. supported_agent_kinds diff --git a/internal/agentdaemon/proto/provider_configuration.go b/internal/agentdaemon/proto/provider_configuration.go new file mode 100644 index 000000000..efac01ce8 --- /dev/null +++ b/internal/agentdaemon/proto/provider_configuration.go @@ -0,0 +1,25 @@ +package proto + +import "slices" + +// AgentProviderConfiguration describes the adapter build's provider inputs, +// independently of binary availability and live operation admission. Older +// peers may omit this optional descriptor, which means unknown support. +type AgentProviderConfiguration struct { + SchemaVersion int `json:"schema_version"` + Providers []AgentProviderProtocol `json:"providers"` +} + +type AgentProviderProtocol struct { + Protocol string `json:"protocol"` + RequiresTokenLimits bool `json:"requires_token_limits"` +} + +func (c *AgentProviderConfiguration) Clone() *AgentProviderConfiguration { + if c == nil { + return nil + } + copy := *c + copy.Providers = slices.Clone(c.Providers) + return © +} diff --git a/internal/harnessconfig/builtin/registry.go b/internal/harnessconfig/builtin/registry.go new file mode 100644 index 000000000..edac027b2 --- /dev/null +++ b/internal/harnessconfig/builtin/registry.go @@ -0,0 +1,18 @@ +// Package builtin composes configuration declarations shared by Core admission +// and the adapter profile registry. It contains no native launch configuration. +package builtin + +import ( + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/claudesdk" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/codex" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/mcode" +) + +var registry = harnessconfig.NewRegistry(map[string]harnessconfig.Configuration{ + "codex": codex.Configuration(), + "claude_sdk": claudesdk.Configuration(), + "mcode": mcode.Configuration(), +}) + +func Registry() harnessconfig.Registry { return registry } diff --git a/internal/harnessconfig/claudesdk/configuration.go b/internal/harnessconfig/claudesdk/configuration.go new file mode 100644 index 000000000..ab955c681 --- /dev/null +++ b/internal/harnessconfig/claudesdk/configuration.go @@ -0,0 +1,8 @@ +// Package claudesdk owns the qualified Claude SDK provider declaration. +package claudesdk + +import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" + +func Configuration() harnessconfig.Configuration { + return harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: "anthropic"}}} +} diff --git a/internal/harnessconfig/codex/configuration.go b/internal/harnessconfig/codex/configuration.go new file mode 100644 index 000000000..42a267ab8 --- /dev/null +++ b/internal/harnessconfig/codex/configuration.go @@ -0,0 +1,8 @@ +// Package codex owns the qualified Codex provider configuration declaration. +package codex + +import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" + +func Configuration() harnessconfig.Configuration { + return harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: "responses"}}} +} diff --git a/internal/harnessconfig/configuration.go b/internal/harnessconfig/configuration.go new file mode 100644 index 000000000..d638349ef --- /dev/null +++ b/internal/harnessconfig/configuration.go @@ -0,0 +1,98 @@ +// Package harnessconfig defines adapter-owned provider configuration support. +// Declarations describe a build, not a live Runtime or provider's readiness. +package harnessconfig + +import ( + "errors" + "slices" +) + +// Provider is deliberately limited to configuration compatibility. Core owns +// credential admission and endpoint security; native launch options stay private. +type Provider struct { + Protocol string + RequiresTokenLimits bool +} + +type Configuration struct { + Providers []Provider +} + +func (c Configuration) Clone() Configuration { + c.Providers = slices.Clone(c.Providers) + return c +} + +func (c Configuration) Provider(protocol string) (Provider, bool) { + for _, provider := range c.Providers { + if provider.Protocol == protocol { + return provider, true + } + } + return Provider{}, false +} + +func (c Configuration) ValidateProtocol(protocol string) error { + if _, ok := c.Provider(protocol); !ok { + return errors.New("selected harness does not support this model provider protocol") + } + return nil +} + +func (c Configuration) Validate(protocol string, contextWindow, maxOutputTokens int32) error { + if err := c.ValidateProtocol(protocol); err != nil { + return err + } + provider, _ := c.Provider(protocol) + if provider.RequiresTokenLimits && (contextWindow <= 0 || maxOutputTokens <= 0) { + return errors.New("selected harness requires positive model context_window and max_output_tokens") + } + return nil +} + +// Registry is an immutable snapshot. Missing declarations remain unknown rather +// than inheriting another harness's configuration rules. +type Registry struct { + configurations map[string]Configuration +} + +func NewRegistry(configurations map[string]Configuration) Registry { + registry := Registry{configurations: make(map[string]Configuration, len(configurations))} + for kind, configuration := range configurations { + registry.configurations[kind] = configuration.Clone() + } + return registry +} + +func (r Registry) Lookup(kind string) (Configuration, bool) { + configuration, ok := r.configurations[kind] + return configuration.Clone(), ok +} + +func (r Registry) Kinds() []string { + kinds := make([]string, 0, len(r.configurations)) + for kind := range r.configurations { + kinds = append(kinds, kind) + } + slices.Sort(kinds) + return kinds +} + +func (r Registry) ValidateProtocol(kind, protocol string) error { + configuration, _ := r.Lookup(kind) + return configuration.ValidateProtocol(protocol) +} + +func (r Registry) Validate(kind, protocol string, contextWindow, maxOutputTokens int32) error { + configuration, _ := r.Lookup(kind) + return configuration.Validate(protocol, contextWindow, maxOutputTokens) +} + +func (r Registry) SupportsProtocol(protocol string) bool { + for _, configuration := range r.configurations { + if _, ok := configuration.Provider(protocol); ok { + return true + } + } + return false +} diff --git a/internal/harnessconfig/configuration_test.go b/internal/harnessconfig/configuration_test.go new file mode 100644 index 000000000..ae556c05f --- /dev/null +++ b/internal/harnessconfig/configuration_test.go @@ -0,0 +1,21 @@ +package harnessconfig + +import "testing" + +func TestRegistryOwnsDeclarations(t *testing.T) { + declaration := Configuration{Providers: []Provider{{Protocol: "fixture", RequiresTokenLimits: true}}} + registry := NewRegistry(map[string]Configuration{"additional-adapter": declaration}) + declaration.Providers[0].Protocol = "changed" + got, ok := registry.Lookup("additional-adapter") + if !ok || got.Validate("fixture", 100, 20) != nil || got.Validate("fixture", 0, 0) == nil || got.ValidateProtocol("unknown") == nil { + t.Fatal("registry did not preserve adapter rules") + } + got.Providers[0].RequiresTokenLimits = false + again, _ := registry.Lookup("additional-adapter") + if again.Validate("fixture", 0, 0) == nil { + t.Fatal("lookup mutated the registered declaration") + } + if _, ok := registry.Lookup("unknown"); ok || registry.SupportsProtocol("changed") || !registry.SupportsProtocol("fixture") { + t.Fatal("unknown declarations were inferred") + } +} diff --git a/internal/harnessconfig/mcode/configuration.go b/internal/harnessconfig/mcode/configuration.go new file mode 100644 index 000000000..a98a6a644 --- /dev/null +++ b/internal/harnessconfig/mcode/configuration.go @@ -0,0 +1,8 @@ +// Package mcode owns the qualified MiniMax Code provider declaration. +package mcode + +import "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" + +func Configuration() harnessconfig.Configuration { + return harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: "anthropic", RequiresTokenLimits: true}}} +} diff --git a/packages/agents-client/saved-agent-defaults.md b/packages/agents-client/saved-agent-defaults.md index 5aca46240..fdd85d319 100644 --- a/packages/agents-client/saved-agent-defaults.md +++ b/packages/agents-client/saved-agent-defaults.md @@ -20,7 +20,7 @@ const agent = await client.createAgent({ }); // Future Sessions inherit the saved defaults; saving itself does not execute. -await client.createSession({ +const session = await client.createSession({ agent_id: agent.id, environment: { type: "openai_hosted" }, input: "Follow the saved Agent instructions.", @@ -44,3 +44,26 @@ member preserves that member. A null provider clears its saved bundle, while defers protocol compatibility to Session admission. Existing Sessions retain their configuration snapshots. Session inline `agent.x_agents_core` remains harness-only; one-off provider overrides belong in the Session's top-level `x_agents_core`. + +Inspect the configuration committed for one Session without reading credentials: + +```ts +const frozen = await client.retrieveSessionExecutionConfiguration(session.id); +console.log(frozen.model.value, frozen.model.source, frozen.harness.value); +// Deployment details are redacted; historical provider snapshots may be unavailable. +if (frozen.model_provider.status === "available") { + console.log(frozen.model_provider.configuration?.protocol); +} + +const startup = await client.retrieveStartupConfiguration({ + includeConfigurationCapabilities: true, +}); +const declarations = startup.configuration_capabilities; +// Build support and deployment enablement do not prove live Runtime readiness. +console.log(declarations?.harnesses, declarations?.runtime_availability); +``` + +The startup option is explicit to preserve the legacy response for older clients. +On servers predating this extension, use the default startup read; requesting the +new include parameter is unsupported. Configuration reads do not execute or wake +Sessions. See [the query contract](../../contracts/agents-api/execution-configuration.md). diff --git a/packages/agents-client/src/client.ts b/packages/agents-client/src/client.ts index 13fd43765..fb2137fdc 100644 --- a/packages/agents-client/src/client.ts +++ b/packages/agents-client/src/client.ts @@ -1,3 +1,4 @@ +import { projectExecutionConfiguration, projectConfigurationCapabilities } from "./execution-configuration-projection"; import { exactFields, onlyFields, isRecord, hasOwn, canonicalUuid, isNonnegativeInteger, sameResourceId } from "./response-projection"; import { projectTokenUsage } from "./usage-projection"; import { projectAgentTurn, projectSessionItem, projectItemContent, projectHistoryPage, validateHistoryPageOptions } from "./history-projection"; @@ -27,6 +28,8 @@ import type { CreateSessionInput, CreateSessionStreamOptions, CoreStartupConfiguration, + SessionExecutionConfiguration, + StartupConfigurationReadOptions, CoreHarnessKind, CoreManagedSandboxProvider, FunctionResultContent, @@ -158,7 +161,7 @@ function invalidStartupConfiguration(): never { } function projectStartupConfiguration(value: unknown): CoreStartupConfiguration { - if (!isRecord(value) || !exactFields(value, startupConfigurationFields) || value.object !== "agents.core.startup_configuration" || value.schema_version !== 1 || + if (!isRecord(value) || !onlyFields(value, new Set([...startupConfigurationFields, "configuration_capabilities"])) || value.object !== "agents.core.startup_configuration" || value.schema_version !== 1 || !isRecord(value.supported) || !exactFields(value.supported, startupSupportedFields) || !sortedUnique(value.supported.harnesses, isHarnessKind) || !sortedUnique(value.supported.managed_sandbox_providers, isSandboxProvider) || !isRecord(value.configured) || !exactFields(value.configured, startupConfiguredFields) || @@ -193,9 +196,15 @@ function projectStartupConfiguration(value: unknown): CoreStartupConfiguration { return invalidStartupConfiguration(); } const projectedProviders = modelProviders as Array>; + const capabilities = hasOwn(value, "configuration_capabilities") + ? projectConfigurationCapabilities(value.configuration_capabilities, invalidStartupConfiguration) : undefined; + if (capabilities && (supportedHarnesses.some((harness) => !capabilities.harnesses.some((entry) => entry.harness === harness)) || capabilities.harnesses.some((entry) => + entry.enabled !== enabledHarnesses.includes(entry.harness as CoreHarnessKind) || + entry.default !== (entry.harness === configured.default_harness)))) return invalidStartupConfiguration(); return { object: "agents.core.startup_configuration", schema_version: 1, + ...(capabilities ? { configuration_capabilities: capabilities } : {}), supported: { harnesses: [...supportedHarnesses], managed_sandbox_providers: [...supportedSandboxProviders], @@ -1854,8 +1863,8 @@ function projectEnvironmentFileList( } export class OpenAIAgentsClient implements AgentCore { - async retrieveStartupConfiguration(options?: ReadOptions): Promise { - const value = await this.request("/agents/core/startup-configuration", { signal: options?.signal }, 200); + async retrieveStartupConfiguration(options?: StartupConfigurationReadOptions): Promise { + const value = await this.request(`/agents/core/startup-configuration${options?.includeConfigurationCapabilities ? "?include=configuration_capabilities" : ""}`, { signal: options?.signal }, 200); return projectStartupConfiguration(value); } @@ -2226,6 +2235,13 @@ export class OpenAIAgentsClient implements AgentCore { }); } + async retrieveSessionExecutionConfiguration(sessionId: string, options?: ReadOptions): Promise { + const value = await this.request(`/agents/sessions/${encodeURIComponent(sessionId)}/execution-configuration`, { signal: options?.signal }); + return projectExecutionConfiguration(value, sessionId, () => { + throw new AgentCoreError("Agent Core returned an invalid execution configuration.", 502, "invalid_execution_configuration"); + }); + } + async retrieveSession(sessionId: string, options?: ReadOptions): Promise { const value = await this.request(`/agents/sessions/${encodeURIComponent(sessionId)}`, { signal: options?.signal }); return projectAgentSession(value, undefined, sessionId); diff --git a/packages/agents-client/src/execution-configuration-projection.ts b/packages/agents-client/src/execution-configuration-projection.ts new file mode 100644 index 000000000..4fc66619c --- /dev/null +++ b/packages/agents-client/src/execution-configuration-projection.ts @@ -0,0 +1,95 @@ +import { canonicalUuid, exactFields, isNonnegativeInteger, isRecord, onlyFields, sameResourceId } from "./response-projection"; +import type { CoreConfigurationCapabilities, ExecutionConfigurationSource, ModelProviderView, SessionExecutionConfiguration } from "./types"; + +type Invalid = () => never; +const sources = new Set(["session", "agent", "deployment", "unknown"]); +const selectionFields = new Set(["value", "source"]); +const providerFields = new Set(["protocol", "base_url", "api_key_configured", "context_window", "max_output_tokens"]); + +function selection(value: unknown, invalid: Invalid): SessionExecutionConfiguration["model"] { + if (!isRecord(value) || !exactFields(value, selectionFields) || !sources.has(String(value.source)) || + (value.value !== null && (typeof value.value !== "string" || value.value.length === 0)) || + (value.value === null && value.source !== "unknown")) return invalid(); + return { value: value.value as string | null, source: value.source as ExecutionConfigurationSource }; +} + +function safeProvider(value: unknown, invalid: Invalid): ModelProviderView { + if (!isRecord(value) || !onlyFields(value, providerFields) || + (value.protocol !== "responses" && value.protocol !== "anthropic") || + typeof value.base_url !== "string" || typeof value.api_key_configured !== "boolean" || + (value.context_window !== undefined && !isNonnegativeInteger(value.context_window)) || + (value.max_output_tokens !== undefined && !isNonnegativeInteger(value.max_output_tokens)) || + Number(value.max_output_tokens ?? 0) > Number(value.context_window ?? 0)) return invalid(); + try { + const url = new URL(value.base_url); + if (url.protocol !== "https:" || !url.hostname || url.username || url.password || url.search || url.hash || /[\r\n\0]/u.test(value.base_url)) return invalid(); + } catch { return invalid(); } + return { + protocol: value.protocol, base_url: value.base_url, api_key_configured: value.api_key_configured, + ...(value.context_window === undefined ? {} : { context_window: value.context_window as number }), + ...(value.max_output_tokens === undefined ? {} : { max_output_tokens: value.max_output_tokens as number }), + }; +} + +export function projectExecutionConfiguration(value: unknown, sessionId: string, invalid: Invalid): SessionExecutionConfiguration { + if (!isRecord(value) || !exactFields(value, new Set(["object", "schema_version", "session_id", "model", "harness", "model_provider"])) || + value.object !== "agent.session.execution_configuration" || value.schema_version !== 1 || + typeof value.session_id !== "string" || !canonicalUuid(value.session_id) || !sameResourceId(value.session_id, sessionId) || + !isRecord(value.model_provider) || !exactFields(value.model_provider, new Set(["source", "status", "configuration"]))) return invalid(); + const provider = value.model_provider; + let configuration: ModelProviderView | null = null; + if (provider.status === "available" && (provider.source === "session" || provider.source === "agent")) { + configuration = safeProvider(provider.configuration, invalid); + } else if (!((provider.status === "redacted" && provider.source === "deployment") || + (provider.status === "unavailable" && provider.source === "unknown")) || provider.configuration !== null) return invalid(); + return { + object: "agent.session.execution_configuration", schema_version: 1, session_id: value.session_id, + model: selection(value.model, invalid), harness: selection(value.harness, invalid), + model_provider: { source: provider.source as ExecutionConfigurationSource, status: provider.status as SessionExecutionConfiguration["model_provider"]["status"], configuration }, + }; +} + +function exactList(value: unknown, expected: string[]): boolean { + return Array.isArray(value) && value.length === expected.length && value.every((entry, index) => entry === expected[index]); +} + +// Public declarations are projected explicitly; private adapter fields cannot +// become public accidentally when an internal capability object grows. +export function projectConfigurationCapabilities(value: unknown, invalid: Invalid): CoreConfigurationCapabilities { + if (!isRecord(value) || !exactFields(value, new Set(["schema_version", "scope", "runtime_availability", "admission", "harnesses"])) || + value.schema_version !== 1 || value.scope !== "core_build_provider_configuration" || value.runtime_availability !== "unknown" || + !isRecord(value.admission) || !exactFields(value.admission, new Set(["credential_environment_types", "base_url", "token_limits"])) || + !Array.isArray(value.harnesses)) return invalid(); + const admission = value.admission; + if (!exactList(admission.credential_environment_types, ["openai_hosted"]) || + !isRecord(admission.base_url) || !exactFields(admission.base_url, new Set(["schemes", "user_info", "query", "fragment"])) || + !exactList(admission.base_url.schemes, ["https"]) || admission.base_url.user_info !== false || admission.base_url.query !== false || admission.base_url.fragment !== false || + !isRecord(admission.token_limits) || !exactFields(admission.token_limits, new Set(["minimum", "max_output_not_above_context"])) || + admission.token_limits.minimum !== 0 || admission.token_limits.max_output_not_above_context !== true) return invalid(); + const harnesses: CoreConfigurationCapabilities["harnesses"] = []; + for (const entry of value.harnesses) { + if (!isRecord(entry) || !exactFields(entry, new Set(["harness", "support", "enabled", "default", "providers"])) || + typeof entry.harness !== "string" || !/^[a-z][a-z0-9_-]{0,63}$/u.test(entry.harness) || + (harnesses.length > 0 && harnesses[harnesses.length - 1]!.harness >= entry.harness) || + (entry.support !== "supported" && entry.support !== "unknown") || typeof entry.enabled !== "boolean" || typeof entry.default !== "boolean" || + !Array.isArray(entry.providers) || (entry.support === "unknown" && entry.providers.length !== 0) || + (entry.support === "supported" && entry.providers.length === 0)) return invalid(); + const providers: CoreConfigurationCapabilities["harnesses"][number]["providers"] = []; + for (const provider of entry.providers) { + if (!isRecord(provider) || !exactFields(provider, new Set(["protocol", "required_fields", "positive_fields"])) || + typeof provider.protocol !== "string" || !/^[a-z][a-z0-9_-]{0,63}$/u.test(provider.protocol) || + providers.some((p) => p.protocol === provider.protocol) || !Array.isArray(provider.required_fields) || !Array.isArray(provider.positive_fields) || + new Set(provider.required_fields).size !== provider.required_fields.length || new Set(provider.positive_fields).size !== provider.positive_fields.length || + !["protocol", "base_url", "api_key"].every((field) => (provider.required_fields as unknown[]).includes(field)) || + !provider.required_fields.every((field) => ["protocol", "base_url", "api_key", "context_window", "max_output_tokens"].includes(field)) || + !provider.positive_fields.every((field) => ["context_window", "max_output_tokens"].includes(field) && (provider.required_fields as unknown[]).includes(field))) return invalid(); + providers.push({ protocol: provider.protocol, required_fields: [...provider.required_fields], positive_fields: [...provider.positive_fields] }); + } + harnesses.push({ harness: entry.harness, support: entry.support, enabled: entry.enabled, default: entry.default, providers }); + } + return { + schema_version: 1, scope: "core_build_provider_configuration", runtime_availability: "unknown", + admission: { credential_environment_types: ["openai_hosted"], base_url: { schemes: ["https"], user_info: false, query: false, fragment: false }, token_limits: { minimum: 0, max_output_not_above_context: true } }, + harnesses, + }; +} diff --git a/packages/agents-client/src/execution-configuration.test.ts b/packages/agents-client/src/execution-configuration.test.ts new file mode 100644 index 000000000..db79b0181 --- /dev/null +++ b/packages/agents-client/src/execution-configuration.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from "vitest"; +import { OpenAIAgentsClient } from "./client"; +import { projectConfigurationCapabilities } from "./execution-configuration-projection"; +import type { CoreConfigurationCapabilities, SessionExecutionConfiguration } from "./types"; + +const id = "013773a9-44b9-4f84-baca-b51c04a01201"; +const snapshot: SessionExecutionConfiguration = { + object: "agent.session.execution_configuration", schema_version: 1, session_id: id, + model: { value: "frozen-model", source: "session" }, harness: { value: "codex", source: "agent" }, + model_provider: { status: "available", source: "agent", configuration: { protocol: "responses", base_url: "https://model.example/v1", api_key_configured: true } }, +}; +function clientReturning(value: unknown, seen?: (url: string, init?: RequestInit) => void) { + return new OpenAIAgentsClient({ token: "project-token", fetch: (async (url: RequestInfo | URL, init?: RequestInit) => { + seen?.(String(url), init); + return new Response(JSON.stringify(value), { headers: { "Content-Type": "application/json" } }); + }) as typeof fetch }); +} + +describe("frozen execution configuration", () => { + it("reads a Session-scoped snapshot with auth and abort signal", async () => { + const abort = new AbortController(); + const client = clientReturning(snapshot, (url, init) => { + expect(url).toContain(`/agents/sessions/${id}/execution-configuration`); + expect(init?.signal).toBe(abort.signal); + expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer project-token"); + }); + expect(await client.retrieveSessionExecutionConfiguration(id, { signal: abort.signal })).toEqual(snapshot); + }); + it.each([ + { status: "redacted", source: "deployment", configuration: null }, + { status: "unavailable", source: "unknown", configuration: null }, + ])("preserves explicit $status provider state", async (provider) => { + const value = { ...snapshot, model_provider: provider }; + expect(await clientReturning(value).retrieveSessionExecutionConfiguration(id)).toEqual(value); + }); + it.each([ + (value: any) => { value.model_provider.configuration.api_key = "secret-canary"; }, + (value: any) => { value.model_provider.configuration.headers = { Authorization: "secret-canary" }; }, + (value: any) => { value.model_provider.source = "deployment"; }, + (value: any) => { value.model_provider.configuration.base_url = "https://user:secret-canary@host/v1"; }, + (value: any) => { value.model_provider.configuration.base_url += "?key=secret-canary"; }, + (value: any) => { value.session_id = "wrong-session"; }, + (value: any) => { value.model.source = "guessed"; }, + ])("rejects unsafe or inconsistent responses without echoing them", async (mutate) => { + const value = structuredClone(snapshot); mutate(value); + await expect(clientReturning(value).retrieveSessionExecutionConfiguration(id)).rejects.toMatchObject({ code: "invalid_execution_configuration" }); + await expect(clientReturning(value).retrieveSessionExecutionConfiguration(id)).rejects.not.toThrow(/secret-canary/u); + }); +}); + +const capabilities: CoreConfigurationCapabilities = { + schema_version: 1, scope: "core_build_provider_configuration", runtime_availability: "unknown", + admission: { credential_environment_types: ["openai_hosted"], base_url: { schemes: ["https"], user_info: false, query: false, fragment: false }, token_limits: { minimum: 0, max_output_not_above_context: true } }, + harnesses: [ + { harness: "extra_test_adapter", support: "supported", enabled: false, default: false, providers: [{ protocol: "anthropic", required_fields: ["protocol", "base_url", "api_key", "context_window", "max_output_tokens"], positive_fields: ["context_window", "max_output_tokens"] }] }, + { harness: "unknown_adapter", support: "unknown", enabled: false, default: false, providers: [] }, + ], +}; +const invalid = (): never => { throw new Error("Invalid safe capabilities"); }; +describe("provider configuration capabilities", () => { + it("accepts registry extensions and unknown declarations without claiming readiness", () => { + const result = projectConfigurationCapabilities(capabilities, invalid); + expect(result).toEqual(capabilities); + expect(result).not.toBe(capabilities); + }); + it.each([ + (value: any) => { value.harnesses[0].providers[0].api_key = "secret-canary"; }, + (value: any) => { value.runtime_availability = "ready"; }, + (value: any) => { value.admission.credential_environment_types.push("none"); }, + (value: any) => { value.harnesses[0].providers[0].required_fields = ["api_key"]; }, + (value: any) => { value.harnesses[1].providers = value.harnesses[0].providers; }, + ])("rejects private fields and contradictory capabilities", (mutate) => { + const value = structuredClone(capabilities); mutate(value); + expect(() => projectConfigurationCapabilities(value, invalid)).toThrow("Invalid safe capabilities"); + }); +}); diff --git a/packages/agents-client/src/startup-configuration.test.ts b/packages/agents-client/src/startup-configuration.test.ts index a0148b9d1..200da6cfd 100644 --- a/packages/agents-client/src/startup-configuration.test.ts +++ b/packages/agents-client/src/startup-configuration.test.ts @@ -50,6 +50,31 @@ describe("Core startup configuration", () => { expect(result.configured.managed_sandbox).toEqual({ enabled: true, provider: "docker", maintenance: false }); }); + it("opts into versioned capabilities while retaining the legacy default", async () => { + const body = fixture() as any; + body.configuration_capabilities = { + schema_version: 1, scope: "core_build_provider_configuration", runtime_availability: "unknown", + admission: { credential_environment_types: ["openai_hosted"], base_url: { schemes: ["https"], user_info: false, query: false, fragment: false }, token_limits: { minimum: 0, max_output_not_above_context: true } }, + harnesses: body.supported.harnesses.map((harness: string) => ({ + harness, support: "supported", enabled: body.configured.enabled_harnesses.includes(harness), default: harness === body.configured.default_harness, + providers: [{ protocol: harness === "codex" ? "responses" : "anthropic", required_fields: ["protocol", "base_url", "api_key"], positive_fields: [] }], + })), + }; + body.configuration_capabilities.harnesses.push({ + harness: "zz_extra_adapter", support: "supported", enabled: false, default: false, + providers: [{ protocol: "anthropic", required_fields: ["protocol", "base_url", "api_key"], positive_fields: [] }], + }); + const urls: string[] = []; + const client = new OpenAIAgentsClient({ fetch: (async (url: RequestInfo | URL) => { + urls.push(String(url)); return response(body); + }) as typeof fetch }); + const result = await client.retrieveStartupConfiguration({ includeConfigurationCapabilities: true }); + expect(urls[0]).toContain("?include=configuration_capabilities"); + expect(result.configuration_capabilities).toEqual(body.configuration_capabilities); + body.configuration_capabilities.harnesses[0].enabled = false; + await expect(client.retrieveStartupConfiguration({ includeConfigurationCapabilities: true })).rejects.toMatchObject({ code: "invalid_startup_configuration" }); + }); + it.each([ ["unknown field", (value: any) => { value.secret = "private"; }], ["unknown harness", (value: any) => { value.supported.harnesses[0] = "future"; }], diff --git a/packages/agents-client/src/types.ts b/packages/agents-client/src/types.ts index 030fd35f3..758891bcc 100644 --- a/packages/agents-client/src/types.ts +++ b/packages/agents-client/src/types.ts @@ -986,7 +986,50 @@ export interface AgentsCoreSelection { harness: CoreHarnessKind; } +export type ExecutionConfigurationSource = "session" | "agent" | "deployment" | "unknown"; + +/** Immutable committed selections, not live execution health. */ +export interface SessionExecutionConfiguration { + object: "agent.session.execution_configuration"; + schema_version: 1; + session_id: string; + model: { value: string | null; source: ExecutionConfigurationSource }; + harness: { value: string | null; source: ExecutionConfigurationSource }; + model_provider: { + source: ExecutionConfigurationSource; + status: "available" | "redacted" | "unavailable"; + configuration: ModelProviderView | null; + }; +} + +export interface CoreConfigurationCapabilities { + schema_version: 1; + scope: "core_build_provider_configuration"; + runtime_availability: "unknown"; + admission: { + credential_environment_types: ["openai_hosted"]; + base_url: { schemes: ["https"]; user_info: false; query: false; fragment: false }; + token_limits: { minimum: 0; max_output_not_above_context: true }; + }; + harnesses: Array<{ + harness: string; + support: "supported" | "unknown"; + enabled: boolean; + default: boolean; + providers: Array<{ + protocol: string; + required_fields: Array<"protocol" | "base_url" | "api_key" | "context_window" | "max_output_tokens">; + positive_fields: Array<"context_window" | "max_output_tokens">; + }>; + }>; +} + +export interface StartupConfigurationReadOptions extends ReadOptions { + includeConfigurationCapabilities?: boolean; +} + export interface CoreStartupConfiguration { + configuration_capabilities?: CoreConfigurationCapabilities; object: "agents.core.startup_configuration"; schema_version: 1; supported: { @@ -1011,7 +1054,8 @@ export interface CoreStartupConfiguration { } export interface AgentCore { - retrieveStartupConfiguration(options?: ReadOptions): Promise; + retrieveSessionExecutionConfiguration(sessionId: string, options?: ReadOptions): Promise; + retrieveStartupConfiguration(options?: StartupConfigurationReadOptions): Promise; listAgents(options?: PageOptions): Promise>; createAgent(input: CreateAgentInput): Promise; retrieveAgent(agentId: string): Promise; diff --git a/scripts/build-agents-api.sh b/scripts/build-agents-api.sh index 94da311a9..ed2f49add 100755 --- a/scripts/build-agents-api.sh +++ b/scripts/build-agents-api.sh @@ -21,7 +21,7 @@ tar -C "$repo_root" -cf - \ go.mod go.sum \ contracts/agents-api/v1 \ internal/agentdaemon/device internal/agentdaemon/gateway internal/agentdaemon/proto \ - internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/obs/log services/agents-api \ + internal/agentnetwork internal/agentbundle internal/agentcapabilities internal/agentplugin internal/agentskill internal/harnessconfig internal/obs/log services/agents-api \ | tar -C "$build_context" -xf - ( diff --git a/services/agents-api/cmd/server/configuration_capabilities.go b/services/agents-api/cmd/server/configuration_capabilities.go new file mode 100644 index 000000000..a47cd3b9d --- /dev/null +++ b/services/agents-api/cmd/server/configuration_capabilities.go @@ -0,0 +1,50 @@ +package main + +import ( + "slices" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" +) + +// configurationCapabilities projects only provider compatibility facts. Runtime +// heartbeats and operation admission remain authoritative for actual execution. +func configurationCapabilities(catalog engine.Catalog, registry harnessconfig.Registry, configured v1.CoreConfiguredStartupConfiguration) *v1.CoreConfigurationCapabilities { + result := &v1.CoreConfigurationCapabilities{ + SchemaVersion: 1, Scope: "core_build_provider_configuration", RuntimeAvailability: "unknown", + Admission: v1.ModelProviderConfigurationAdmission(), Harnesses: []v1.CoreHarnessConfigurationSupport{}, + } + kinds := append(catalog.Kinds(), registry.Kinds()...) + slices.Sort(kinds) + for _, kind := range slices.Compact(kinds) { + entry := v1.CoreHarnessConfigurationSupport{ + Harness: kind, Support: "unknown", Enabled: slices.Contains(configured.EnabledHarnesses, kind), + Default: configured.DefaultHarness == kind, Providers: []v1.CoreProviderProtocolSupport{}, + } + if declaration, ok := registry.Lookup(kind); ok && len(declaration.Providers) != 0 { + entry.Support = "supported" + for _, provider := range declaration.Providers { + view := v1.CoreProviderProtocolSupport{ + Protocol: provider.Protocol, RequiredFields: []string{"protocol", "base_url", "api_key"}, PositiveFields: []string{}, + } + if provider.RequiresTokenLimits { + view.RequiredFields = append(view.RequiredFields, "context_window", "max_output_tokens") + view.PositiveFields = []string{"context_window", "max_output_tokens"} + } + entry.Providers = append(entry.Providers, view) + } + slices.SortFunc(entry.Providers, func(a, b v1.CoreProviderProtocolSupport) int { + if a.Protocol < b.Protocol { + return -1 + } + if a.Protocol > b.Protocol { + return 1 + } + return 0 + }) + } + result.Harnesses = append(result.Harnesses, entry) + } + return result +} diff --git a/services/agents-api/cmd/server/configuration_capabilities_test.go b/services/agents-api/cmd/server/configuration_capabilities_test.go new file mode 100644 index 000000000..5e64dd6d3 --- /dev/null +++ b/services/agents-api/cmd/server/configuration_capabilities_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "encoding/json" + "reflect" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" +) + +func TestConfigurationCapabilitiesFollowDeployment(t *testing.T) { + for _, gateway := range []bool{false, true} { + got := coreStartupConfiguration("codex", []string{"codex"}, gateway, nil, "", nil).ConfigurationCapabilities + if got.SchemaVersion != 1 || got.Scope != "core_build_provider_configuration" || got.RuntimeAvailability != "unknown" || len(got.Harnesses) != 3 { + t.Fatalf("unexpected capabilities: %#v", got) + } + for _, harness := range got.Harnesses { + if harness.Support != "supported" || harness.Enabled != (gateway && harness.Harness == "codex") || harness.Default != (harness.Harness == "codex") { + t.Fatalf("deployment state confused with support: %#v", harness) + } + if len(harness.Providers) != 1 { + t.Fatalf("missing provider: %#v", harness) + } + provider := harness.Providers[0] + input := v1.ModelProviderInput{Protocol: provider.Protocol, BaseURL: "https://example.test", APIKey: "private-fixture"} + needsLimits := len(provider.PositiveFields) > 0 + if (input.ValidateHarness(harness.Harness) != nil) != needsLimits { + t.Fatalf("advertised requirements disagree with admission: %#v", harness) + } + want := []string{"protocol", "base_url", "api_key"} + if needsLimits { + want = append(want, "context_window", "max_output_tokens") + } + if !reflect.DeepEqual(provider.RequiredFields, want) { + t.Fatalf("required fields = %v", provider.RequiredFields) + } + } + } +} + +func TestAdditionalAdapterAndUnknownDeclarationUseCommonProjection(t *testing.T) { + declaration := harnessconfig.Configuration{Providers: []harnessconfig.Provider{{Protocol: "zeta"}, {Protocol: "alpha", RequiresTokenLimits: true}}} + registry := harnessconfig.NewRegistry(map[string]harnessconfig.Configuration{"additional-adapter": declaration}) + catalog := engine.NewCatalog(map[string]engine.Profile{"undeclared": {}}) + configured := v1.CoreConfiguredStartupConfiguration{DefaultHarness: "undeclared", EnabledHarnesses: []string{"undeclared"}} + got := configurationCapabilities(catalog, registry, configured) + if len(got.Harnesses) != 2 || got.Harnesses[0].Harness != "additional-adapter" || got.Harnesses[0].Support != "supported" || got.Harnesses[0].Default || got.Harnesses[0].Enabled { + t.Fatalf("additional registration was not projected: %#v", got) + } + if got.Harnesses[0].Providers[0].Protocol != "alpha" || got.Harnesses[0].Providers[1].Protocol != "zeta" || len(got.Harnesses[0].Providers[0].PositiveFields) != 2 { + t.Fatal("provider order or requirements lost") + } + unknown := got.Harnesses[1] + if unknown.Support != "unknown" || unknown.Providers == nil || len(unknown.Providers) != 0 { + t.Fatalf("unknown support inferred: %#v", unknown) + } + input := &v1.ModelProviderInput{Protocol: "alpha", BaseURL: "https://example.test", APIKey: "fixture", ContextWindow: 100, MaxOutputTokens: 20} + if input.ValidateHarnessWithRegistry("additional-adapter", registry) != nil || input.SafeView().ValidateHarnessWithRegistry("additional-adapter", registry) != nil { + t.Fatal("discovered declaration did not validate through the shared admission path") + } + input.MaxOutputTokens = 0 + if input.ValidateHarnessWithRegistry("additional-adapter", registry) == nil || input.SafeView().ValidateHarnessWithRegistry("additional-adapter", registry) == nil { + t.Fatal("advertised required limit was not enforced") + } + input.Protocol = "zeta" + if input.ValidateHarnessWithRegistry("additional-adapter", registry) != nil { + t.Fatal("protocol without required limits was rejected") + } + if input.ValidateHarness("additional-adapter") == nil { + t.Fatal("test registry widened production provider admission") + } + if _, ok := catalog.Lookup("additional-adapter"); ok { + t.Fatal("provider registration enabled an execution profile") + } + first, _ := json.Marshal(got) + for i := 0; i < 10; i++ { + next, _ := json.Marshal(configurationCapabilities(catalog, registry, configured)) + if string(first) != string(next) { + t.Fatal("capabilities changed between reads") + } + } +} + +func TestConfigurationAdmissionMatchesCorePolicy(t *testing.T) { + admission := configurationCapabilities(engine.Catalog{}, builtin.Registry(), v1.CoreConfiguredStartupConfiguration{}).Admission + if !reflect.DeepEqual(admission.CredentialEnvironmentTypes, []string{"openai_hosted"}) || !reflect.DeepEqual(admission.BaseURL.Schemes, []string{"https"}) || admission.BaseURL.UserInfo || admission.BaseURL.Query || admission.BaseURL.Fragment || admission.TokenLimits.Minimum != 0 || !admission.TokenLimits.MaxOutputNotAboveContext { + t.Fatalf("unexpected Core restrictions: %#v", admission) + } + for _, environment := range []string{"none", "self_hosted", "openai_hosted", "unknown"} { + if v1.ModelProviderEnvironmentSupported(environment) != (environment == "openai_hosted") { + t.Fatal("credential environment admission disagrees") + } + } + for _, base := range []string{"http://example.test", "https://user:private@example.test", "https://example.test?private=value", "https://example.test#private"} { + input := v1.ModelProviderInput{Protocol: "responses", BaseURL: base, APIKey: "fixture"} + if input.Validate() == nil { + t.Fatal("advertised endpoint restriction not enforced") + } + } + for _, limits := range [][2]int32{{-1, 0}, {100, -1}, {100, 101}} { + input := v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://example.test", APIKey: "fixture", ContextWindow: limits[0], MaxOutputTokens: limits[1]} + if input.Validate() == nil { + t.Fatal("advertised token restriction not enforced") + } + } +} diff --git a/services/agents-api/cmd/server/execution_options_test.go b/services/agents-api/cmd/server/execution_options_test.go index 518bca84f..f996cdbd7 100644 --- a/services/agents-api/cmd/server/execution_options_test.go +++ b/services/agents-api/cmd/server/execution_options_test.go @@ -97,7 +97,7 @@ func TestExecutionOptionsConfigurationReportsOnlyEndpointPresence(t *testing.T) if err != nil { t.Fatal(err) } - for _, private := range []string{"example.test", "user:secret", "token=private", "codex-secret", "claude-secret", "mcode-secret", "base_url", "baseURL", "apiKey", "bearer_token"} { + for _, private := range []string{"example.test", "user:secret", "token=private", "codex-secret", "claude-secret", "mcode-secret", `"base_url":"`, `"api_key":`, "baseURL", "apiKey", "bearer_token"} { if strings.Contains(string(encoded), private) { t.Fatalf("private execution option %q leaked into startup projection: %s", private, encoded) } diff --git a/services/agents-api/cmd/server/startup_configuration.go b/services/agents-api/cmd/server/startup_configuration.go index c9cc93d26..91b4870b4 100644 --- a/services/agents-api/cmd/server/startup_configuration.go +++ b/services/agents-api/cmd/server/startup_configuration.go @@ -2,6 +2,7 @@ package main import ( v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/engine" "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/execution" ) @@ -20,7 +21,7 @@ func coreStartupConfiguration(defaultHarness string, enabledHarnesses []string, provider := managedProviderKind managedConfiguration = v1.CoreManagedSandboxConfiguration{Enabled: true, Provider: &provider, Maintenance: managed.Maintenance} } - return v1.CoreStartupConfiguration{ + result := v1.CoreStartupConfiguration{ Object: "agents.core.startup_configuration", SchemaVersion: 1, Supported: v1.CoreSupportedConfiguration{ Harnesses: (engine.Catalog{}).Kinds(), ManagedSandboxProviders: []string{"docker", "microsandbox"}, @@ -30,4 +31,6 @@ func coreStartupConfiguration(defaultHarness string, enabledHarnesses []string, ManagedSandbox: managedConfiguration, ModelProviders: modelProviders, }, } + result.ConfigurationCapabilities = configurationCapabilities(engine.Catalog{}, builtin.Registry(), result.Configured) + return result } diff --git a/services/agents-api/internal/api/handler.go b/services/agents-api/internal/api/handler.go index efa9ba580..6877311e8 100644 --- a/services/agents-api/internal/api/handler.go +++ b/services/agents-api/internal/api/handler.go @@ -118,6 +118,7 @@ func NewHandler(s ResourceStore, auth *Authenticator, engine string, options ... r.Post("/agents/sessions", h.createSession) r.Get("/agents/sessions", h.listSessions) r.Get("/agents/sessions/{session_id}", h.getSession) + r.Get("/agents/sessions/{session_id}/execution-configuration", h.getSessionExecutionConfiguration) r.Get("/agents/sessions/{session_id}/runtime-observation", h.getRuntimeObservation) r.Get("/agents/runtime-observations", h.listRuntimeObservations) r.Get("/agents/runtime-history/capabilities", h.getRuntimeHistoryCapabilities) @@ -259,10 +260,12 @@ func (h *Handler) createSession(w http.ResponseWriter, r *http.Request) { writeError(w, http.StatusServiceUnavailable, "execution_unavailable", "Hosted execution is not configured on this service.") return } + executionConfiguration := sessionExecutionProjection(input, saved, inheritedProvider, provider, selectedEngine, configuration) createInput := store.CreateSessionInput{ - ModelProvider: provider, - ModelOptions: providerOptions, - Creator: sessionCreator(r), InitialFiles: input.initialFiles, Initialization: input.initialization, + ExecutionConfiguration: &executionConfiguration, + ModelProvider: provider, + ModelOptions: providerOptions, + Creator: sessionCreator(r), InitialFiles: input.initialFiles, Initialization: input.initialization, Engine: selectedEngine, IdempotencyKey: key, Metadata: input.Metadata, Configuration: configuration, InitialInputs: initialInputs, CreationRequest: creationRequest, } if input.XAgentsCore != nil && input.XAgentsCore.SandboxNodeID != nil { diff --git a/services/agents-api/internal/api/session_execution_configuration.go b/services/agents-api/internal/api/session_execution_configuration.go new file mode 100644 index 000000000..e3955d3d9 --- /dev/null +++ b/services/agents-api/internal/api/session_execution_configuration.go @@ -0,0 +1,79 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/go-chi/chi/v5" +) + +type sessionExecutionConfigurationStore interface { + GetSessionExecutionConfiguration(context.Context, string, string) (v1.SessionExecutionConfiguration, error) +} + +// Record selection sources at resolution time. Null Agent extensions reset the +// harness to deployment defaults but do not clear inherited provider bundles. +func sessionExecutionProjection(input sessionRequest, saved *v1.SavedAgent, inherited, provider *v1.ModelProviderInput, engine string, raw json.RawMessage) v1.SessionExecutionConfiguration { + var configuration struct { + Agent struct { + Model string `json:"model"` + } `json:"agent"` + } + _ = json.Unmarshal(raw, &configuration) // The resolved configuration was already validated. + modelSource := "session" + if saved != nil && (input.Agent == nil || input.Agent.Model == nil) { + modelSource = "agent" + } + harnessSource := "deployment" + if _, overridden := input.agentFields["x_agents_core"]; overridden { + if input.Agent != nil && input.Agent.XAgentsCore != nil && input.Agent.XAgentsCore.Harness != "" { + harnessSource = "session" + } + } else if saved != nil && saved.XAgentsCore != nil && saved.XAgentsCore.Harness != "" { + harnessSource = "agent" + } + selection := v1.ExecutionProviderSelection{Source: "unknown", Status: "unavailable"} + if provider != nil { + selection.Source, selection.Status = "deployment", "redacted" + if input.XAgentsCore != nil && input.XAgentsCore.ModelProvider != nil { + selection.Source = "session" + } else if inherited != nil { + selection.Source = "agent" + } + if selection.Source != "deployment" { + selection.Status, selection.Configuration = "available", provider.SafeView() + } + } + return v1.SessionExecutionConfiguration{ + Object: "agent.session.execution_configuration", SchemaVersion: 1, + Model: v1.ExecutionSelection{Value: &configuration.Agent.Model, Source: modelSource}, + Harness: v1.ExecutionSelection{Value: &engine, Source: harnessSource}, ModelProvider: selection, + } +} + +// @Summary Retrieve frozen Session execution configuration +// @Description Returns the committed model, harness and safe provider selection with recorded sources. This read never decrypts credentials, resolves current defaults or probes execution health. Deployment provider details remain redacted for project callers. Historical provenance and missing provider projections are explicitly unknown/unavailable. +// @Tags Core extensions +// @Produce json +// @Security BearerAuth +// @Param OpenAI-Beta header string true "agents=v1" +// @Param session_id path string true "Session ID" +// @Success 200 {object} v1.SessionExecutionConfiguration +// @Failure 400,401,404,500,503 {object} v1.ErrorResponse +// @Router /agents/sessions/{session_id}/execution-configuration [get] +func (h *Handler) getSessionExecutionConfiguration(w http.ResponseWriter, r *http.Request) { + source, ok := h.store.(sessionExecutionConfigurationStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "execution_configuration_unavailable", "Session execution configuration is unavailable.") + return + } + configuration, err := source.GetSessionExecutionConfiguration(r.Context(), tenantID(r), chi.URLParam(r, "session_id")) + if err != nil { + writeStoreError(w, r, err) + return + } + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, configuration) +} diff --git a/services/agents-api/internal/api/session_execution_configuration_test.go b/services/agents-api/internal/api/session_execution_configuration_test.go new file mode 100644 index 000000000..feffbee6e --- /dev/null +++ b/services/agents-api/internal/api/session_execution_configuration_test.go @@ -0,0 +1,101 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/store" +) + +func TestExecutionConfigurationSources(t *testing.T) { + provider := &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://saved.example/v1", APIKey: "secret-canary"} + saved := &v1.SavedAgent{SavedAgentConfiguration: v1.SavedAgentConfiguration{Model: "saved-model", XAgentsCore: &v1.SavedAgentCore{Harness: "codex", ModelProvider: provider.SafeView()}}} + for _, tc := range []struct { + name, agent, extension string + saved *v1.SavedAgent + inherited, provider *v1.ModelProviderInput + modelSource, harnessSource, providerSource, status string + }{ + {"saved", ``, ``, saved, provider, provider, "agent", "agent", "agent", "available"}, + {"model override", `,"agent":{"model":"override"}`, ``, saved, provider, provider, "session", "agent", "agent", "available"}, + {"harness override", `,"agent":{"x_agents_core":{"harness":"codex"}}`, ``, saved, provider, provider, "agent", "session", "agent", "available"}, + {"harness reset", `,"agent":{"x_agents_core":null}`, ``, saved, provider, provider, "agent", "deployment", "agent", "available"}, + {"explicit bundle", ``, `,"x_agents_core":{"model_provider":{"protocol":"responses","base_url":"https://explicit.example/v1","api_key":"explicit-secret"}}`, saved, nil, provider, "agent", "agent", "session", "available"}, + {"provider null inherits", ``, `,"x_agents_core":{"model_provider":null}`, saved, provider, provider, "agent", "agent", "agent", "available"}, + {"inline deployment", `,"agent":{"model":"inline"}`, ``, nil, nil, provider, "session", "deployment", "deployment", "redacted"}, + {"inline explicit harness", `,"agent":{"model":"inline","x_agents_core":{"harness":"codex"}}`, ``, nil, nil, nil, "session", "session", "unknown", "unavailable"}, + } { + t.Run(tc.name, func(t *testing.T) { + var decoded decodedSessionRequest + if err := json.Unmarshal([]byte(`{"environment":{"type":"openai_hosted"}`+tc.agent+tc.extension+`}`), &decoded); err != nil { + t.Fatal(err) + } + input, err := decoded.validated() + if err != nil { + t.Fatal(err) + } + p := sessionExecutionProjection(input, tc.saved, tc.inherited, tc.provider, "codex", json.RawMessage(`{"agent":{"model":"resolved"}}`)) + if p.Model.Source != tc.modelSource || p.Harness.Source != tc.harnessSource || p.ModelProvider.Source != tc.providerSource || p.ModelProvider.Status != tc.status { + t.Fatalf("wrong sources: %#v", p) + } + raw, _ := json.Marshal(p) + if strings.Contains(string(raw), "secret-canary") || (tc.status != "available" && p.ModelProvider.Configuration != nil) { + t.Fatal("private provider leaked") + } + }) + } +} + +type executionConfigurationReader struct { + ResourceStore + calls int + tenant, id string + value v1.SessionExecutionConfiguration + err error +} + +func (s *executionConfigurationReader) GetSessionExecutionConfiguration(_ context.Context, tenant, id string) (v1.SessionExecutionConfiguration, error) { + s.calls++ + s.tenant, s.id = tenant, id + return s.value, s.err +} + +func TestExecutionConfigurationReadBoundary(t *testing.T) { + s := &executionConfigurationReader{value: v1.SessionExecutionConfiguration{Object: "agent.session.execution_configuration", SchemaVersion: 1, SessionID: "frozen"}} + h, _, tenant := testHandler(t, func(h *Handler) { h.store = s }) + for _, tc := range []struct { + auth string + err error + status int + }{ + {"", nil, 401}, {"Bearer test-api-key", nil, 200}, {"Bearer test-api-key", store.ErrNotFound, 404}, + } { + s.err = tc.err + before := s.calls + r := httptest.NewRequest(http.MethodGet, "/v1/agents/sessions/frozen/execution-configuration?ignored=true", nil) + r.Header.Set("Authorization", tc.auth) + r.Header.Set("OpenAI-Beta", "agents=v1") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != tc.status { + t.Fatalf("status %d: %s", w.Code, w.Body) + } + if tc.auth == "" { + if s.calls != before { + t.Fatal("unauthorized read reached storage") + } + continue + } + if s.tenant != tenant || s.id != "frozen" { + t.Fatal("wrong tenant/resource") + } + if w.Code == 200 && w.Header().Get("Cache-Control") != "no-store" { + t.Fatal("snapshot response cacheable") + } + } +} diff --git a/services/agents-api/internal/api/session_model_defaults.go b/services/agents-api/internal/api/session_model_defaults.go index 73fc41233..ef5a232c5 100644 --- a/services/agents-api/internal/api/session_model_defaults.go +++ b/services/agents-api/internal/api/session_model_defaults.go @@ -83,7 +83,7 @@ func (h *Handler) resolveSessionExecution(ctx context.Context, input sessionRequ } } if provider != nil { - if input.Environment.Type != "openai_hosted" { + if !v1.ModelProviderEnvironmentSupported(input.Environment.Type) { return "", nil, nil, errors.New("caller model credentials currently require a hosted environment") } if err := provider.ValidateHarness(engine); err != nil { diff --git a/services/agents-api/internal/api/startup_configuration.go b/services/agents-api/internal/api/startup_configuration.go index 15445a8bd..4e9279aa0 100644 --- a/services/agents-api/internal/api/startup_configuration.go +++ b/services/agents-api/internal/api/startup_configuration.go @@ -2,6 +2,7 @@ package api import ( "net/http" + "net/url" "slices" v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" @@ -16,6 +17,7 @@ func WithStartupConfiguration(configuration v1.CoreStartupConfiguration) Option copy.Supported.ManagedSandboxProviders = slices.Clone(configuration.Supported.ManagedSandboxProviders) copy.Configured.EnabledHarnesses = slices.Clone(configuration.Configured.EnabledHarnesses) copy.Configured.ModelProviders = slices.Clone(configuration.Configured.ModelProviders) + copy.ConfigurationCapabilities = configuration.ConfigurationCapabilities.Clone() if configuration.Configured.ManagedSandbox.Provider != nil { provider := *configuration.Configured.ManagedSandbox.Provider copy.Configured.ManagedSandbox.Provider = &provider @@ -30,18 +32,28 @@ func WithStartupConfiguration(configuration v1.CoreStartupConfiguration) Option // @Produce json // @Security BearerAuth // @Param OpenAI-Beta header string true "agents=v1" +// @Param include query string false "Opt in to versioned provider configuration capabilities" Enums(configuration_capabilities) // @Success 200 {object} v1.CoreStartupConfiguration // @Failure 400,401,503 {object} v1.ErrorResponse // @Router /agents/core/startup-configuration [get] func (h *Handler) getStartupConfiguration(w http.ResponseWriter, r *http.Request) { + includeCapabilities := false if r.URL.RawQuery != "" { - writeError(w, http.StatusBadRequest, "unsupported_parameter", "Core startup configuration does not accept query parameters.") - return + query, err := url.ParseQuery(r.URL.RawQuery) + if err != nil || len(query) != 1 || len(query["include"]) != 1 || query.Get("include") != "configuration_capabilities" { + writeError(w, http.StatusBadRequest, "unsupported_parameter", "Core startup configuration only accepts include=configuration_capabilities.") + return + } + includeCapabilities = true } if h.startup == nil { writeError(w, http.StatusServiceUnavailable, "startup_configuration_unavailable", "Core startup configuration is unavailable.") return } + view := *h.startup + if !includeCapabilities { + view.ConfigurationCapabilities = nil + } w.Header().Set("Cache-Control", "no-store") - writeJSON(w, http.StatusOK, h.startup) + writeJSON(w, http.StatusOK, view) } diff --git a/services/agents-api/internal/api/startup_configuration_test.go b/services/agents-api/internal/api/startup_configuration_test.go index 62da24d89..8b5202cc0 100644 --- a/services/agents-api/internal/api/startup_configuration_test.go +++ b/services/agents-api/internal/api/startup_configuration_test.go @@ -58,6 +58,11 @@ func TestStartupConfigurationRejectsUnsupportedReads(t *testing.T) { {name: "missing auth", option: []Option{WithStartupConfiguration(configured)}, path: "/v1/agents/core/startup-configuration", status: http.StatusUnauthorized}, {name: "query", option: []Option{WithStartupConfiguration(configured)}, path: "/v1/agents/core/startup-configuration?raw=true", auth: "Bearer test-api-key", status: http.StatusBadRequest}, {name: "malformed query", option: []Option{WithStartupConfiguration(configured)}, path: "/v1/agents/core/startup-configuration?raw;private", auth: "Bearer test-api-key", status: http.StatusBadRequest}, + {name: "unknown include", option: []Option{WithStartupConfiguration(configured)}, path: "/v1/agents/core/startup-configuration?include=private", auth: "Bearer test-api-key", status: http.StatusBadRequest}, + {name: "empty include", option: []Option{WithStartupConfiguration(configured)}, path: "/v1/agents/core/startup-configuration?include=", auth: "Bearer test-api-key", status: http.StatusBadRequest}, + {name: "duplicate include", option: []Option{WithStartupConfiguration(configured)}, path: "/v1/agents/core/startup-configuration?include=configuration_capabilities&include=configuration_capabilities", auth: "Bearer test-api-key", status: http.StatusBadRequest}, + {name: "additional key", option: []Option{WithStartupConfiguration(configured)}, path: "/v1/agents/core/startup-configuration?include=configuration_capabilities&raw=true", auth: "Bearer test-api-key", status: http.StatusBadRequest}, + {name: "malformed encoding", option: []Option{WithStartupConfiguration(configured)}, path: "/v1/agents/core/startup-configuration?include=%GG", auth: "Bearer test-api-key", status: http.StatusBadRequest}, {name: "composition missing", path: "/v1/agents/core/startup-configuration", auth: "Bearer test-api-key", status: http.StatusServiceUnavailable}, } { t.Run(test.name, func(t *testing.T) { @@ -73,3 +78,68 @@ func TestStartupConfigurationRejectsUnsupportedReads(t *testing.T) { }) } } + +func TestConfigurationCapabilitiesHTTPIsStableAndDefensive(t *testing.T) { + capabilities := &v1.CoreConfigurationCapabilities{ + SchemaVersion: 1, Scope: "core_build_provider_configuration", RuntimeAvailability: "unknown", + Admission: v1.ModelProviderConfigurationAdmission(), + Harnesses: []v1.CoreHarnessConfigurationSupport{{ + Harness: "additional-adapter", Support: "supported", Enabled: true, Default: true, + Providers: []v1.CoreProviderProtocolSupport{{Protocol: "fixture", RequiredFields: []string{"protocol", "base_url", "api_key", "context_window", "max_output_tokens"}, PositiveFields: []string{"context_window", "max_output_tokens"}}}, + }}, + } + configuration := v1.CoreStartupConfiguration{Object: "agents.core.startup_configuration", SchemaVersion: 1, ConfigurationCapabilities: capabilities} + h, _, _ := testHandler(t, WithStartupConfiguration(configuration)) + capabilities.Admission.CredentialEnvironmentTypes[0] = "private-canary" + capabilities.Admission.BaseURL.Schemes[0] = "private-canary" + capabilities.Harnesses[0].Harness = "private-canary" + capabilities.Harnesses[0].Providers[0].Protocol = "private-canary" + capabilities.Harnesses[0].Providers[0].RequiredFields[0] = "private-canary" + capabilities.Harnesses[0].Providers[0].PositiveFields[0] = "private-canary" + + previous := "" + for i := 0; i < 2; i++ { + request := httptest.NewRequest(http.MethodGet, "/v1/agents/core/startup-configuration?include=configuration_capabilities", nil) + request.Header.Set("Authorization", "Bearer test-api-key") + request.Header.Set("OpenAI-Beta", "agents=v1") + w := httptest.NewRecorder() + h.ServeHTTP(w, request) + if w.Code != http.StatusOK || strings.Contains(w.Body.String(), "private-canary") { + t.Fatalf("unsafe response: %d %s", w.Code, w.Body) + } + if i > 0 && w.Body.String() != previous { + t.Fatal("startup response changed between reads") + } + previous = w.Body.String() + var got v1.CoreStartupConfiguration + if json.Unmarshal(w.Body.Bytes(), &got) != nil || got.ConfigurationCapabilities == nil { + t.Fatal("missing capability extension") + } + entry := got.ConfigurationCapabilities.Harnesses[0] + if entry.Harness != "additional-adapter" || entry.Providers[0].RequiredFields[0] != "protocol" || entry.Providers[0].PositiveFields[0] != "context_window" { + t.Fatal("shared snapshot was mutated") + } + if got.ConfigurationCapabilities.RuntimeAvailability != "unknown" { + t.Fatal("startup view advertised Runtime readiness") + } + } + request := httptest.NewRequest(http.MethodGet, "/v1/agents/core/startup-configuration", nil) + request.Header.Set("Authorization", "Bearer test-api-key") + request.Header.Set("OpenAI-Beta", "agents=v1") + w := httptest.NewRecorder() + h.ServeHTTP(w, request) + var legacy map[string]json.RawMessage + if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &legacy) != nil || len(legacy) != 4 || legacy["configuration_capabilities"] != nil { + t.Fatalf("default read broke the legacy startup shape: %d %s", w.Code, w.Body) + } + for _, auth := range []string{"", "Bearer wrong-private-canary"} { + request := httptest.NewRequest(http.MethodGet, "/v1/agents/core/startup-configuration?api_key=private-canary", nil) + request.Header.Set("Authorization", auth) + request.Header.Set("OpenAI-Beta", "agents=v1") + w := httptest.NewRecorder() + h.ServeHTTP(w, request) + if w.Code != http.StatusUnauthorized || strings.Contains(w.Body.String(), "private-canary") || strings.Contains(w.Body.String(), "additional-adapter") { + t.Fatalf("unauthorized configuration exposure: %d %s", w.Code, w.Body) + } + } +} diff --git a/services/agents-api/internal/db/queries/session_execution_configuration.sql b/services/agents-api/internal/db/queries/session_execution_configuration.sql new file mode 100644 index 000000000..c59f9bab6 --- /dev/null +++ b/services/agents-api/internal/db/queries/session_execution_configuration.sql @@ -0,0 +1,10 @@ +-- name: SaveSessionExecutionConfiguration :exec +INSERT INTO session_execution_configuration(session_id, configuration) +VALUES (@session_id, @configuration); + +-- name: GetSessionExecutionConfiguration :one +SELECT s.id, s.engine, s.configuration AS session_configuration, + p.configuration AS execution_configuration +FROM sessions s +LEFT JOIN session_execution_configuration p ON p.session_id = s.id +WHERE s.tenant_id = @tenant_id AND s.id = @session_id AND s.deleted_at IS NULL; diff --git a/services/agents-api/internal/db/sqlc/models.go b/services/agents-api/internal/db/sqlc/models.go index 2c85af614..49834bf9f 100644 --- a/services/agents-api/internal/db/sqlc/models.go +++ b/services/agents-api/internal/db/sqlc/models.go @@ -282,6 +282,11 @@ type SessionEvent struct { PayloadBytes pgtype.Int4 `json:"payload_bytes"` } +type SessionExecutionConfiguration struct { + SessionID pgtype.UUID `json:"session_id"` + Configuration []byte `json:"configuration"` +} + type SessionItem struct { ID pgtype.UUID `json:"id"` SessionID pgtype.UUID `json:"session_id"` diff --git a/services/agents-api/internal/db/sqlc/session_execution_configuration.sql.go b/services/agents-api/internal/db/sqlc/session_execution_configuration.sql.go new file mode 100644 index 000000000..9cc3ad926 --- /dev/null +++ b/services/agents-api/internal/db/sqlc/session_execution_configuration.sql.go @@ -0,0 +1,59 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.29.0 +// source: session_execution_configuration.sql + +package sqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getSessionExecutionConfiguration = `-- name: GetSessionExecutionConfiguration :one +SELECT s.id, s.engine, s.configuration AS session_configuration, + p.configuration AS execution_configuration +FROM sessions s +LEFT JOIN session_execution_configuration p ON p.session_id = s.id +WHERE s.tenant_id = $1 AND s.id = $2 AND s.deleted_at IS NULL +` + +type GetSessionExecutionConfigurationParams struct { + TenantID pgtype.UUID `json:"tenant_id"` + SessionID pgtype.UUID `json:"session_id"` +} + +type GetSessionExecutionConfigurationRow struct { + ID pgtype.UUID `json:"id"` + Engine string `json:"engine"` + SessionConfiguration []byte `json:"session_configuration"` + ExecutionConfiguration []byte `json:"execution_configuration"` +} + +func (q *Queries) GetSessionExecutionConfiguration(ctx context.Context, arg GetSessionExecutionConfigurationParams) (GetSessionExecutionConfigurationRow, error) { + row := q.db.QueryRow(ctx, getSessionExecutionConfiguration, arg.TenantID, arg.SessionID) + var i GetSessionExecutionConfigurationRow + err := row.Scan( + &i.ID, + &i.Engine, + &i.SessionConfiguration, + &i.ExecutionConfiguration, + ) + return i, err +} + +const saveSessionExecutionConfiguration = `-- name: SaveSessionExecutionConfiguration :exec +INSERT INTO session_execution_configuration(session_id, configuration) +VALUES ($1, $2) +` + +type SaveSessionExecutionConfigurationParams struct { + SessionID pgtype.UUID `json:"session_id"` + Configuration []byte `json:"configuration"` +} + +func (q *Queries) SaveSessionExecutionConfiguration(ctx context.Context, arg SaveSessionExecutionConfigurationParams) error { + _, err := q.db.Exec(ctx, saveSessionExecutionConfiguration, arg.SessionID, arg.Configuration) + return err +} diff --git a/services/agents-api/internal/engine/configuration_test.go b/services/agents-api/internal/engine/configuration_test.go new file mode 100644 index 000000000..b615a6140 --- /dev/null +++ b/services/agents-api/internal/engine/configuration_test.go @@ -0,0 +1,39 @@ +package engine + +import ( + "testing" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/harnessconfig/builtin" +) + +func TestProviderDeclarationsAgreeWithAdmission(t *testing.T) { + for _, kind := range (Catalog{}).Kinds() { + t.Run(kind, func(t *testing.T) { + declared, ok := builtin.Registry().Lookup(kind) + if !ok { + t.Fatal("qualified harness has no provider declaration") + } + for _, protocol := range []string{"responses", "anthropic", "unknown"} { + provider, supported := declared.Provider(protocol) + input := v1.ModelProviderInput{Protocol: protocol, BaseURL: "https://example.test", APIKey: "private-fixture", ContextWindow: 100, MaxOutputTokens: 20} + if (input.ValidateHarness(kind) == nil) != supported || (v1.ValidateModelProtocol(protocol, kind) == nil) != supported { + t.Fatalf("protocol %q disagrees with validation", protocol) + } + if !supported { + continue + } + input.ContextWindow, input.MaxOutputTokens = 0, 0 + if (input.ValidateHarness(kind) != nil) != provider.RequiresTokenLimits { + t.Fatalf("token requirements disagree for %q", protocol) + } + if provider.RequiresTokenLimits { + input.ContextWindow = 100 + if input.ValidateHarness(kind) == nil { + t.Fatal("missing output limit accepted") + } + } + } + }) + } +} diff --git a/services/agents-api/internal/store/session_execution_configuration.go b/services/agents-api/internal/store/session_execution_configuration.go new file mode 100644 index 000000000..b93e0bdf3 --- /dev/null +++ b/services/agents-api/internal/store/session_execution_configuration.go @@ -0,0 +1,125 @@ +package store + +import ( + "context" + "encoding/json" + "errors" + "fmt" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/db/sqlc" + "github.com/google/uuid" + "github.com/jackc/pgx/v5" +) + +// The projection is creation metadata, excluded from retry identity. Only the +// transaction that inserts the Session writes it; retries cannot repair or alter it. +func saveSessionExecutionConfiguration(ctx context.Context, q *sqlc.Queries, session sqlc.Session, projection *v1.SessionExecutionConfiguration, provider *v1.ModelProviderInput) error { + if projection == nil { + return nil + } + frozen := *projection + model, err := sessionExecutionModel(session.Configuration) + if err != nil { + return err + } + if !sameExecutionValue(frozen.Model.Value, model) || frozen.Harness.Value == nil || *frozen.Harness.Value != session.Engine || + !validExecutionSource(frozen.Model.Source) || !validExecutionSource(frozen.Harness.Source) { + return fmt.Errorf("%w: execution projection does not match Session configuration", ErrInvalidInput) + } + switch frozen.ModelProvider.Source { + case "deployment": + if provider == nil { + return fmt.Errorf("%w: execution projection has no model provider", ErrInvalidInput) + } + // Deployment endpoints and all native options remain private, even if an + // internal caller accidentally supplies a public provider view. + frozen.ModelProvider.Status = "redacted" + frozen.ModelProvider.Configuration = nil + case "session", "agent": + if provider == nil || frozen.ModelProvider.Status != "available" || frozen.ModelProvider.Configuration == nil || *frozen.ModelProvider.Configuration != *provider.SafeView() { + return fmt.Errorf("%w: execution projection does not match model provider", ErrInvalidInput) + } + case "unknown": + frozen.ModelProvider.Status = "unavailable" + frozen.ModelProvider.Configuration = nil + default: + return fmt.Errorf("%w: invalid execution projection source", ErrInvalidInput) + } + normalizeExecutionProjection(&frozen, uuid.UUID(session.ID.Bytes).String()) + raw, err := json.Marshal(frozen) + if err != nil { + return err + } + return q.SaveSessionExecutionConfiguration(ctx, sqlc.SaveSessionExecutionConfigurationParams{SessionID: session.ID, Configuration: raw}) +} + +// GetSessionExecutionConfiguration reads only safe committed configuration. It +// never loads provider ciphertext, current defaults or runtime health. +func (s *Store) GetSessionExecutionConfiguration(ctx context.Context, tenantID, sessionID string) (v1.SessionExecutionConfiguration, error) { + tenant, err := parseID(tenantID) + if err != nil { + return v1.SessionExecutionConfiguration{}, err + } + row, err := s.queries.GetSessionExecutionConfiguration(ctx, sqlc.GetSessionExecutionConfigurationParams{TenantID: tenant, SessionID: parsePathID(sessionID)}) + if errors.Is(err, pgx.ErrNoRows) { + return v1.SessionExecutionConfiguration{}, ErrNotFound + } + if err != nil { + return v1.SessionExecutionConfiguration{}, fmt.Errorf("get session execution configuration: %w", err) + } + var projection v1.SessionExecutionConfiguration + if len(row.ExecutionConfiguration) == 0 { + model, err := sessionExecutionModel(row.SessionConfiguration) + if err != nil { + return projection, err + } + var harness *string + if row.Engine != "" { + harness = &row.Engine + } + projection.Model = v1.ExecutionSelection{Value: model, Source: "unknown"} + projection.Harness = v1.ExecutionSelection{Value: harness, Source: "unknown"} + projection.ModelProvider = v1.ExecutionProviderSelection{Source: "unknown", Status: "unavailable"} + } else if err := json.Unmarshal(row.ExecutionConfiguration, &projection); err != nil { + return v1.SessionExecutionConfiguration{}, errors.New("invalid stored session execution configuration") + } + normalizeExecutionProjection(&projection, uuid.UUID(row.ID.Bytes).String()) + return projection, nil +} + +func normalizeExecutionProjection(projection *v1.SessionExecutionConfiguration, sessionID string) { + projection.Object = "agent.session.execution_configuration" + projection.SchemaVersion = 1 + projection.SessionID = sessionID + if projection.ModelProvider.Source == "deployment" { + projection.ModelProvider.Status = "redacted" + projection.ModelProvider.Configuration = nil + } else if projection.ModelProvider.Status != "available" { + projection.ModelProvider.Configuration = nil + } +} + +func sessionExecutionModel(configuration []byte) (*string, error) { + var config struct { + Agent struct { + Model *string `json:"model"` + } `json:"agent"` + } + if err := json.Unmarshal(configuration, &config); err != nil { + return nil, errors.New("invalid stored Session model configuration") + } + return config.Agent.Model, nil +} + +func sameExecutionValue(a, b *string) bool { + return a == nil && b == nil || a != nil && b != nil && *a == *b +} + +func validExecutionSource(source string) bool { + switch source { + case "session", "agent", "deployment", "unknown": + return true + } + return false +} diff --git a/services/agents-api/internal/store/session_execution_configuration_test.go b/services/agents-api/internal/store/session_execution_configuration_test.go new file mode 100644 index 000000000..31c507521 --- /dev/null +++ b/services/agents-api/internal/store/session_execution_configuration_test.go @@ -0,0 +1,291 @@ +package store + +import ( + "bytes" + "encoding/json" + "errors" + "reflect" + "sync" + "testing" + "time" + + v1 "github.com/MiniMax-AI-Dev/parsar/contracts/agents-api/v1" + "github.com/MiniMax-AI-Dev/parsar/internal/agentdaemon/device" + "github.com/MiniMax-AI-Dev/parsar/services/agents-api/internal/credentialcrypto" + "github.com/google/uuid" +) + +func executionProjectionInput(source string) CreateSessionInput { + model, harness := "frozen-model", "codex" + return CreateSessionInput{ + Creator: FixtureCreator(), Engine: harness, IdempotencyKey: uuid.NewString(), + Configuration: []byte(`{"agent":{"model":"frozen-model"},"environment":{"type":"openai_hosted"}}`), + ExecutionConfiguration: &v1.SessionExecutionConfiguration{ + Model: v1.ExecutionSelection{Value: &model, Source: source}, + Harness: v1.ExecutionSelection{Value: &harness, Source: source}, + ModelProvider: v1.ExecutionProviderSelection{Source: "unknown", Status: "unavailable"}, + }, + } +} + +func TestSessionExecutionConfigurationFrozenAcrossCreationPathsAndRetry(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{41}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + for _, stream := range []bool{false, true} { + for _, source := range []string{"session", "agent", "deployment"} { + t.Run(source+map[bool]string{false: "/ordinary", true: "/stream"}[stream], func(t *testing.T) { + tenant := uuid.NewString() + input := executionProjectionInput(source) + input.ModelProvider = &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://private-deployment.example/v1", APIKey: "private-projection-key-canary"} + input.ModelOptions = map[string]any{"private_headers": map[string]any{"authorization": "private-header-canary"}} + input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: source, Status: "available", Configuration: input.ModelProvider.SafeView()} + input.ExecutionConfiguration.Object = "untrusted-object" + input.ExecutionConfiguration.SchemaVersion = 99 + input.ExecutionConfiguration.SessionID = "untrusted-session" + var session Session + var err error + if stream { + created, e := s.CreateSessionStream(t.Context(), tenant, input) + session, err = created.Session, e + } else { + session, err = s.CreateSession(t.Context(), tenant, input) + } + if err != nil { + t.Fatal(err) + } + // A reader without the encryption key can use the safe snapshot after restart. + reader := New(pool) + frozen, err := reader.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + if frozen.Object != "agent.session.execution_configuration" || frozen.SchemaVersion != 1 || frozen.SessionID != session.ID || frozen.Model.Source != source || frozen.Harness.Source != source { + t.Fatal("incorrect frozen projection identity or provenance") + } + if source == "deployment" { + if frozen.ModelProvider.Status != "redacted" || frozen.ModelProvider.Configuration != nil { + t.Fatal("deployment provider was not redacted") + } + } else if frozen.ModelProvider.Status != "available" || !reflect.DeepEqual(frozen.ModelProvider.Configuration, input.ModelProvider.SafeView()) { + t.Fatal("safe provider projection changed") + } + var stored []byte + if err := pool.QueryRow(t.Context(), "SELECT configuration FROM session_execution_configuration WHERE session_id=$1", session.ID).Scan(&stored); err != nil { + t.Fatal(err) + } + for _, secret := range []string{"private-projection-key-canary", "private-header-canary", "private_headers", "native_options"} { + if bytes.Contains(stored, []byte(secret)) { + t.Fatal("secret entered safe projection") + } + } + if source == "deployment" && bytes.Contains(stored, []byte("private-deployment.example")) { + t.Fatal("deployment endpoint persisted publicly") + } + // Source-only changes and invalid replacement metadata never alter the + // existing Session's retry identity or projection. + input.ExecutionConfiguration.Model.Source = "different-on-retry" + input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: "unknown", Status: "unavailable"} + replay, err := s.CreateSession(t.Context(), tenant, input) + if err != nil || replay.ID != session.ID { + t.Fatal("projection metadata changed retry identity", err) + } + if _, err := s.UpdateSessionMetadata(t.Context(), tenant, session.ID, map[string]string{"edited": "yes"}); err != nil { + t.Fatal(err) + } + if _, err := pool.Exec(t.Context(), "UPDATE session_model_execution SET encrypted_config='\\x00'::bytea WHERE session_id=$1", session.ID); err != nil { + t.Fatal(err) + } + got, err := reader.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) + if err != nil || !reflect.DeepEqual(got, frozen) { + t.Fatal("snapshot changed or reader decrypted a secret", err) + } + for _, lookup := range []struct{ tenant, id string }{{uuid.NewString(), session.ID}, {tenant, uuid.NewString()}, {tenant, "malformed"}} { + if _, err := reader.GetSessionExecutionConfiguration(t.Context(), lookup.tenant, lookup.id); !errors.Is(err, ErrNotFound) { + t.Fatal("foreign/missing projection read differed", err) + } + } + if err := s.DeleteSession(t.Context(), tenant, session.ID); err != nil { + t.Fatal(err) + } + if _, err := reader.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID); !errors.Is(err, ErrNotFound) { + t.Fatal("deleted Session projection remained public", err) + } + }) + } + } +} + +func TestSessionExecutionConfigurationHistoricalProvenance(t *testing.T) { + s, pool := testStore(t) + tenant := uuid.NewString() + for _, configuration := range []string{`{}`, `{"agent":{"model":null}}`, `{"agent":{"model":"historical-model"},"model_provider_configured":true}`} { + input := CreateSessionInput{Creator: FixtureCreator(), Engine: "codex", IdempotencyKey: uuid.NewString(), Configuration: []byte(configuration)} + session, err := s.CreateSession(t.Context(), tenant, input) + if err != nil { + t.Fatal(err) + } + got, err := s.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + if got.Model.Source != "unknown" || got.Harness.Source != "unknown" || got.Harness.Value == nil || *got.Harness.Value != "codex" || got.ModelProvider.Source != "unknown" || got.ModelProvider.Status != "unavailable" || got.ModelProvider.Configuration != nil { + t.Fatal("historical provenance guessed") + } + if bytes.Contains([]byte(configuration), []byte("historical-model")) { + if got.Model.Value == nil || *got.Model.Value != "historical-model" { + t.Fatal("historical model lost") + } + } else if got.Model.Value != nil { + t.Fatal("missing historical model invented") + } + input.ExecutionConfiguration = executionProjectionInput("session").ExecutionConfiguration + if _, err := s.CreateSession(t.Context(), tenant, input); err != nil { + t.Fatal("historical retry changed", err) + } + var count int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM session_execution_configuration WHERE session_id=$1", session.ID).Scan(&count); err != nil || count != 0 { + t.Fatal("historical snapshot was backfilled", err) + } + } +} + +func TestSessionExecutionConfigurationRollbackAndValidation(t *testing.T) { + _, pool := testStore(t) + cipher, err := credentialcrypto.New(bytes.Repeat([]byte{42}, 32)) + if err != nil { + t.Fatal(err) + } + s := NewWithCredentialCipher(pool, cipher) + tenant := uuid.NewString() + for _, kind := range []string{"model", "harness", "source", "provider", "provider_mismatch", "post_projection_failure"} { + input := executionProjectionInput("session") + switch kind { + case "model": + value := "different-model" + input.ExecutionConfiguration.Model.Value = &value + case "harness": + value := "different-harness" + input.ExecutionConfiguration.Harness.Value = &value + case "source": + input.ExecutionConfiguration.Model.Source = "invalid" + case "provider": + input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: "session", Status: "available", Configuration: &v1.ModelProviderView{Protocol: "responses", BaseURL: "https://example.com"}} + case "provider_mismatch": + input.ModelProvider = &v1.ModelProviderInput{Protocol: "responses", BaseURL: "https://actual.example/v1", APIKey: "private-key-canary"} + input.ExecutionConfiguration.ModelProvider = v1.ExecutionProviderSelection{Source: "session", Status: "available", Configuration: input.ModelProvider.SafeView()} + input.ExecutionConfiguration.ModelProvider.Configuration.BaseURL = "https://different.example/v1" + case "post_projection_failure": + input.InitialFiles = []InitialFile{{Type: "inline", Path: "invalid-path"}} + } + if _, err := s.CreateSession(t.Context(), tenant, input); !errors.Is(err, ErrInvalidInput) { + t.Fatalf("%s: invalid projection/creation accepted: %v", kind, err) + } + } + var count int + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM sessions WHERE tenant_id=$1", tenant).Scan(&count); err != nil || count != 0 { + t.Fatal("rejected projection left Session", err) + } + if err := pool.QueryRow(t.Context(), "SELECT count(*) FROM session_execution_configuration p LEFT JOIN sessions s ON s.id=p.session_id WHERE s.id IS NULL").Scan(&count); err != nil || count != 0 { + t.Fatal("rejected creation left orphan projection", err) + } +} + +func TestSessionExecutionConfigurationConcurrentRetryKeepsWinner(t *testing.T) { + s, _ := testStore(t) + tenant := uuid.NewString() + input := executionProjectionInput("session") + var wg sync.WaitGroup + projections := make(chan v1.SessionExecutionConfiguration, 8) + for i := 0; i < 8; i++ { + request := input + projection := *input.ExecutionConfiguration + if i%2 == 1 { + projection.Model.Source = "agent" + projection.Harness.Source = "deployment" + } + request.ExecutionConfiguration = &projection + wg.Add(1) + go func() { + defer wg.Done() + created, err := s.CreateSessionStream(t.Context(), tenant, request) + if err != nil { + t.Error(err) + return + } + got, err := s.GetSessionExecutionConfiguration(t.Context(), tenant, created.Session.ID) + if err != nil { + t.Error(err) + return + } + projections <- got + }() + } + wg.Wait() + close(projections) + var winner *v1.SessionExecutionConfiguration + for got := range projections { + if winner == nil { + winner = &got + } else if !reflect.DeepEqual(*winner, got) { + t.Fatal("concurrent retry rewrote provenance") + } + } + if winner == nil { + t.Fatal("no committed projection") + } +} + +func TestSessionExecutionConfigurationSurvivesSuspendResume(t *testing.T) { + s, pool := testStore(t) + w := executionLease(t, s).Store() + tenant := uuid.NewString() + session, err := s.CreateSession(t.Context(), tenant, executionProjectionInput("agent")) + if err != nil { + t.Fatal(err) + } + frozen, err := s.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + environment, err := s.GetSessionEnvironment(t.Context(), tenant, session.ID) + if err != nil { + t.Fatal(err) + } + owner, err := w.ReserveRuntimeAllocation(t.Context(), tenant, environment.ID, uuid.NewString(), device.HashCredential(uuid.NewString())) + if err != nil { + t.Fatal(err) + } + owner, err = w.ObserveRuntimeRunning(t.Context(), owner) + if err != nil { + t.Fatal(err) + } + owner, err = w.SetRuntimeCompute(t.Context(), owner, "running", json.RawMessage(`{"instance":"original"}`), nil, 0) + if err != nil { + t.Fatal(err) + } + runtimeSuspensionCompleted(t, pool, owner) + until := time.Now().Add(time.Hour) + for _, phase := range []string{"quiescing", "suspending", "suspended", "restoring", "waking", "running"} { + retained := &until + if phase == "running" { + retained = nil + } + owner = runtimeSuspensionStep(t, w, owner, phase, retained) + before, err := w.RuntimeActivity(t.Context(), owner) + if err != nil { + t.Fatal(err) + } + got, err := s.GetSessionExecutionConfiguration(t.Context(), tenant, session.ID) + if err != nil || !reflect.DeepEqual(got, frozen) { + t.Fatal("runtime transition changed execution projection", phase, err) + } + after, err := w.RuntimeActivity(t.Context(), owner) + if err != nil || !before.LastActivity.Equal(after.LastActivity) || before.WakeRequested != after.WakeRequested || before.Busy != after.Busy || before.HasCompletedTurn != after.HasCompletedTurn { + t.Fatal("configuration read changed runtime activity", err) + } + } +} diff --git a/services/agents-api/internal/store/session_initial_input.go b/services/agents-api/internal/store/session_initial_input.go index 6166350b8..0bcb230fd 100644 --- a/services/agents-api/internal/store/session_initial_input.go +++ b/services/agents-api/internal/store/session_initial_input.go @@ -23,7 +23,7 @@ func validateInitialInputs(inputs []Input) ([]Input, json.RawMessage, error) { // The Session upsert locks retries. Only the new row reserves or admits work, so a // retry after completion or later Turns cannot submit the original input again. -func (s *Store) createSessionResources(ctx context.Context, tenant string, params sqlc.CreateSessionParams, inputs []Input, encodedInput json.RawMessage, files []InitialFile, setup EnvironmentSetup, provider *v1.ModelProviderInput, modelOptions map[string]any, sandboxNodeID string) (sqlc.Session, *Environment, error) { +func (s *Store) createSessionResources(ctx context.Context, tenant string, params sqlc.CreateSessionParams, inputs []Input, encodedInput json.RawMessage, files []InitialFile, setup EnvironmentSetup, provider *v1.ModelProviderInput, modelOptions map[string]any, sandboxNodeID string, executionConfiguration *v1.SessionExecutionConfiguration) (sqlc.Session, *Environment, error) { var row sqlc.Session var environment *Environment err := pgx.BeginFunc(ctx, s.pool, func(tx pgx.Tx) error { @@ -62,6 +62,9 @@ func (s *Store) createSessionResources(ctx context.Context, tenant string, param if err := s.saveSessionModelExecution(ctx, q, tenant, row.ID, provider, modelOptions); err != nil { return err } + if err := saveSessionExecutionConfiguration(ctx, q, row, executionConfiguration, provider); err != nil { + return err + } if len(files) > 0 { metadata, err := s.saveInitialFiles(ctx, q, tx, tenant, row.ID, files) if err != nil { diff --git a/services/agents-api/internal/store/sessions.go b/services/agents-api/internal/store/sessions.go index ed1567881..696457611 100644 --- a/services/agents-api/internal/store/sessions.go +++ b/services/agents-api/internal/store/sessions.go @@ -54,8 +54,9 @@ type Session struct { } type CreateSessionInput struct { - SandboxNodeID string - ModelProvider *v1.ModelProviderInput + ExecutionConfiguration *v1.SessionExecutionConfiguration + SandboxNodeID string + ModelProvider *v1.ModelProviderInput // ModelOptions is a private snapshot of trusted deployment execution options. ModelOptions map[string]any Initialization EnvironmentSetup @@ -139,7 +140,8 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create return SessionCreation{}, ErrInvalidInput } environment, _ := fields["environment"].(map[string]any) - if environment["type"] != "openai_hosted" { + environmentType, _ := environment["type"].(string) + if !v1.ModelProviderEnvironmentSupported(environmentType) { return SessionCreation{}, fmt.Errorf("%w: model credentials require a hosted environment", ErrInvalidInput) } fields["model_provider_configured"] = true @@ -178,7 +180,7 @@ func (s *Store) createSession(ctx context.Context, tenantID string, input Create Configuration: configuration, CreationRequestHash: creationHash, CreatorKind: pgtype.Text{String: input.Creator.Kind, Valid: true}, CreatorID: pgtype.Text{String: input.Creator.ID, Valid: true}, } - row, environment, err := s.createSessionResources(ctx, tenantID, params, batch, encodedInput, input.InitialFiles, input.Initialization, input.ModelProvider, input.ModelOptions, input.SandboxNodeID) + row, environment, err := s.createSessionResources(ctx, tenantID, params, batch, encodedInput, input.InitialFiles, input.Initialization, input.ModelProvider, input.ModelOptions, input.SandboxNodeID, input.ExecutionConfiguration) if errors.Is(err, pgx.ErrNoRows) { return SessionCreation{}, ErrIdempotencyConflict } diff --git a/services/agents-api/migrations/000060_session_execution_configuration.sql b/services/agents-api/migrations/000060_session_execution_configuration.sql new file mode 100644 index 000000000..d8ded49b5 --- /dev/null +++ b/services/agents-api/migrations/000060_session_execution_configuration.sql @@ -0,0 +1,8 @@ +-- +goose Up +CREATE TABLE session_execution_configuration ( + session_id uuid PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, + configuration jsonb NOT NULL CHECK (jsonb_typeof(configuration) = 'object') +); + +-- +goose Down +DROP TABLE session_execution_configuration;