Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 31 additions & 31 deletions contracts/agents-api/core.openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -429,7 +429,7 @@ definitions:
api.Installation:
properties:
address_bindings:
$ref: '#/definitions/store.AddressBindings'
$ref: '#/definitions/deployment.AddressBindings'
api_base_url:
description: public_url followed by /v1; null when public_url is null.
type: string
Expand Down Expand Up @@ -902,6 +902,17 @@ definitions:
service:
$ref: '#/definitions/coremetrics.ServiceState'
type: object
deployment.AddressBindings:
properties:
hosted_sandboxes:
type: integer
nodes:
type: integer
nodes_on_other_address:
type: integer
self_hosted_executors:
type: integer
type: object
deployment.HostHistory:
properties:
points:
Expand Down Expand Up @@ -1150,6 +1161,24 @@ definitions:
on_offline_nodes:
type: integer
type: object
deployment.ResetRequest:
properties:
clear:
enum:
- auto
- force
type: string
deadline_seconds:
maximum: 86400
minimum: 300
type: integer
expected_generation:
minimum: 0
type: integer
required:
- clear
- expected_generation
type: object
deployment.Resources:
properties:
allocations:
Expand Down Expand Up @@ -1364,17 +1393,6 @@ definitions:
state:
type: string
type: object
store.AddressBindings:
properties:
hosted_sandboxes:
type: integer
nodes:
type: integer
nodes_on_other_address:
type: integer
self_hosted_executors:
type: integer
type: object
store.AdminAssetCounts:
properties:
agents:
Expand Down Expand Up @@ -1419,24 +1437,6 @@ definitions:
tenant_id:
type: string
type: object
store.SandboxResetRequest:
properties:
clear:
enum:
- auto
- force
type: string
deadline_seconds:
maximum: 86400
minimum: 300
type: integer
expected_generation:
minimum: 0
type: integer
required:
- clear
- expected_generation
type: object
v1.Agent:
properties:
id:
Expand Down Expand Up @@ -6326,7 +6326,7 @@ paths:
name: body
required: true
schema:
$ref: '#/definitions/store.SandboxResetRequest'
$ref: '#/definitions/deployment.ResetRequest'
produces:
- application/json
responses:
Expand Down
2 changes: 1 addition & 1 deletion services/core/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ Domain owners, each with its PostgreSQL adapter under `internal/persistence/post
- `environmenttemplates` (`templatepg`): Environment Templates, their validation and default network, their sealed setup, initial files, Skills and Plugins, and the resolved Template that Session creation composes into its Environment.
- `modelconfiguration` (`modelconfigurationpg`): each Harness's deployment default model configuration and its last-use observations.
- `skills` (`skillpg`): Skills and their immutable versions: archive checks, the default and latest pointers, version selection and deletion, and each version's sealed archive. Session creation freezes selected versions inside its `store` transaction with the `skills` rules.
- `deployment` (`deploymentpg`): the sandbox deployment and its nodes: provider configuration and the sealed credential, the specification and retained generations, setup, update and switch, node enrollment, identity and authentication, generation configuration, capacity, presence, status and host history. It interprets Sandbox Provider declarations through the `providers.Registry` it is given, whose lookups return typed errors. `cmd/server` builds that registry and calls it only to build a direct Provider and to discover a Provider's configuration; it takes each setup's mode and declared operations from the `Setup` that `deployment` returns. The only other reader is `store`, whose own `providers.Builtin()` validates the stored specification at Session admission and reads the public-origin requirement at placement. Deployment changes run through `deployment.ExecutionOperations` on `deploymentpg.NewExecution(lease, …)`, which the Worker receives as `execution.Owner.Deployment`; the Worker reads the deployment and prepares a selection's setup through the pooled `deployment.Service` in `execution.Dispatcher.Deployment`, and node management and reads use the pooled `deploymentpg.Store`, which `cmd/server` also reads the owner epoch from and runs the host-history sampler on. Provider calls run outside transactions, and the final transaction rechecks the expected generation. Allocations, placement and reset stay in `store`.
- `deployment` (`deploymentpg`): the sandbox deployment and its nodes: provider configuration and the sealed credential, the specification and retained generations, setup, update and switch, node enrollment, identity and authentication, generation configuration, capacity, presence, status, host history, reset, and the counts of nodes and sandboxes bound to the public address. It interprets Sandbox Provider declarations through the `providers.Registry` it is given, whose lookups return typed errors. `cmd/server` builds that registry and calls it only to build a direct Provider and to discover a Provider's configuration; it takes each setup's mode and declared operations from the `Setup` that `deployment` returns. The only other reader is `store`, whose own `providers.Builtin()` validates the stored specification at Session admission and reads the public-origin requirement at placement. Deployment changes run through `deployment.ExecutionOperations` on `deploymentpg.NewExecution(lease, …)`, which the Worker receives as `execution.Owner.Deployment`; the Worker reads the deployment and prepares a selection's setup through the pooled `deployment.Service` in `execution.Dispatcher.Deployment` and lists the Sessions a reset still has to archive through the pooled `deployment.Reader` in `execution.Dispatcher.DeploymentReader`, and node management and reads use the pooled `deploymentpg.Store`, which `cmd/server` also reads the owner epoch from and runs the host-history sampler on. Provider calls run outside transactions, and the final transaction rechecks the expected generation. Allocations, placement, reset admission and the reset's Session archive stay in `store`.
- `sessions` (`sessionpg`): Session use cases and reads as they leave `store`. The pooled `sessions.Service` runs the use cases on `sessionpg.Store`, which implements `sessions.Storage`, and plain reads use `sessions.Reader`, which `sessionpg.Store` also implements, directly. So far these cover root Items and Subagents (their reads) and Session Artifacts (their reads, deletion and the staging of a Turn's export). `cmd/server` builds one `sessionpg.Store` and wires the Service and the Store into `execution.Dispatcher.Sessions` and `SessionsReader` and into the api fields; the Worker stages Artifacts through the Service.

## Request handling
Expand Down
4 changes: 2 additions & 2 deletions services/core/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -289,7 +289,7 @@ func run() error {
}
if registry != nil {
dispatcher := &execution.Dispatcher{Store: executionStore, Registry: registry,
Credentials: vaultService, Observer: modelConfigurationStore, Deployment: deploymentService,
Credentials: vaultService, Observer: modelConfigurationStore, Deployment: deploymentService, DeploymentReader: deploymentStore,
Sessions: sessionService,
SessionsReader: sessionStore,
ManagedRuntimes: managed, MaxConcurrentExecutions: concurrency}
Expand Down Expand Up @@ -370,7 +370,7 @@ func run() error {
}
deps := api.Dependencies{
Engine: engine, Harnesses: kinds, CoreKeys: keyAdmin,
Installation: installation, InstallationBindings: executionStore,
Installation: installation, InstallationBindings: deploymentService,
Projects: projectService, ProjectsReader: projectStore,
ModelProviders: modelConfigurationService, ModelProvidersReader: modelConfigurationStore,
Vaults: vaultService, VaultsReader: vaultStore,
Expand Down
12 changes: 3 additions & 9 deletions services/core/internal/api/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,16 +86,10 @@ func writeFieldError(w http.ResponseWriter, err error) bool {
}

// writeStoreError reports a failure of an operation that can meet a sandbox
// reset or a deployment's sandbox resources, then the Session errors.
// reset or the sandbox deployment, then the Session errors.
func writeStoreError(w http.ResponseWriter, r *http.Request, err error) {
// A reset's resource check unwraps to deployment.ErrConflict and admission
// paused by a reset is Session admission's, so both precede the deployment errors.
var inUse *store.SandboxInUseError
switch {
case errors.As(err, &inUse):
writeCoreError(w, http.StatusConflict, "sandbox_in_use", "Hosted sandbox resources still belong to this deployment.", CoreErrorDetails{"allocations": CoreErrorNumber(float64(inUse.Resources.Allocations)), "pending": CoreErrorNumber(float64(inUse.Resources.Pending))})
return
case errors.Is(err, store.ErrSandboxResetAdmission):
// Admission paused by a reset is Session admission's.
if errors.Is(err, store.ErrSandboxResetAdmission) {
writeError(w, http.StatusServiceUnavailable, "sandbox_reset_in_progress", "A sandbox reset is in progress.")
return
}
Expand Down
4 changes: 4 additions & 0 deletions services/core/internal/api/errors_deployment.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,11 @@ func writeSandboxError(w http.ResponseWriter, err error) bool {
var deploymentConfiguration *deployment.ConfigurationError
var stale *deployment.GenerationStaleError
var resetRequired *deployment.ResetRequiredError
var inUse *deployment.InUseError
switch {
// Reset completion's resource check unwraps to deployment.ErrConflict.
case errors.As(err, &inUse):
writeCoreError(w, http.StatusConflict, "sandbox_in_use", "Hosted sandbox resources still belong to this deployment.", CoreErrorDetails{"allocations": CoreErrorNumber(float64(inUse.Resources.Allocations)), "pending": CoreErrorNumber(float64(inUse.Resources.Pending))})
case errors.As(err, &configuration):
status := http.StatusInternalServerError
switch configuration.Class {
Expand Down
8 changes: 4 additions & 4 deletions services/core/internal/api/fakes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,11 +299,11 @@ func (f *fakeDeploymentChanges) UpdateSandboxDeployment(a0 context.Context, a1 s

type fakeDeploymentReset struct {
t testing.TB
startSandboxReset func(context.Context, store.SandboxResetRequest) (deployment.View, error)
startSandboxReset func(context.Context, deployment.ResetRequest) (deployment.View, error)
cancelSandboxReset func(context.Context, uint64) (deployment.View, error)
}

func (f *fakeDeploymentReset) StartSandboxReset(a0 context.Context, a1 store.SandboxResetRequest) (deployment.View, error) {
func (f *fakeDeploymentReset) StartSandboxReset(a0 context.Context, a1 deployment.ResetRequest) (deployment.View, error) {
if f.startSandboxReset == nil {
unexpectedCall(f.t, "StartSandboxReset")
}
Expand Down Expand Up @@ -527,10 +527,10 @@ func (f *fakeInputAdmission) SubmitInputs(a0 context.Context, a1 string, a2 stri

type fakeInstallationBindings struct {
t testing.TB
addressBindings func(context.Context) (store.AddressBindings, error)
addressBindings func(context.Context) (deployment.AddressBindings, error)
}

func (f *fakeInstallationBindings) AddressBindings(a0 context.Context) (store.AddressBindings, error) {
func (f *fakeInstallationBindings) AddressBindings(a0 context.Context) (deployment.AddressBindings, error) {
if f.addressBindings == nil {
unexpectedCall(f.t, "AddressBindings")
}
Expand Down
8 changes: 4 additions & 4 deletions services/core/internal/api/installation.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"slices"
"time"

"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment"
)

// Installation reports Core's installation facts. Core reads them from its
Expand All @@ -31,7 +31,7 @@ type Installation struct {
SourceCommit *string `json:"source_commit" extensions:"x-nullable"`
// The installer's settings snapshot (OAC_SETTINGS_FILE); null when the installer did not start Core.
Configuration *InstallationConfiguration `json:"configuration" extensions:"x-nullable"`
AddressBindings store.AddressBindings `json:"address_bindings"`
AddressBindings deployment.AddressBindings `json:"address_bindings"`
}

// InstallationConfiguration says where process settings are changed and what
Expand Down Expand Up @@ -99,7 +99,7 @@ func ParseInstallationConfiguration(raw []byte) (*InstallationConfiguration, err

// InstallationBindings counts what is bound to the current public URL.
type InstallationBindings interface {
AddressBindings(context.Context) (store.AddressBindings, error)
AddressBindings(context.Context) (deployment.AddressBindings, error)
}

// @Summary Retrieve installation facts and process settings
Expand All @@ -113,7 +113,7 @@ type InstallationBindings interface {
func (h *Handler) getInstallation(w http.ResponseWriter, r *http.Request) {
bindings, err := h.InstallationBindings.AddressBindings(r.Context())
if err != nil {
writeStoreError(w, r, err)
writeDeploymentError(w, r, err)
return
}
value := h.Installation
Expand Down
5 changes: 2 additions & 3 deletions services/core/internal/api/installation_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import (
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/deployment"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/sandbox/providers"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store"
)

func TestInstallationReadNeedsOnlyTheCoreKey(t *testing.T) {
Expand All @@ -25,8 +24,8 @@ func TestInstallationReadNeedsOnlyTheCoreKey(t *testing.T) {
if err != nil {
t.Fatal(err)
}
fakes.installationBindings.addressBindings = func(context.Context) (store.AddressBindings, error) {
return store.AddressBindings{Nodes: 2, NodesOnOtherAddress: 1}, nil
fakes.installationBindings.addressBindings = func(context.Context) (deployment.AddressBindings, error) {
return deployment.AddressBindings{Nodes: 2, NodesOnOtherAddress: 1}, nil
}
// No sandbox deployment: the read is available before any deployment exists.
deps.Installation = Installation{InstallationID: &id, PublicURL: &public, Configuration: settings}
Expand Down
61 changes: 35 additions & 26 deletions services/core/internal/api/sandbox_deployment_changes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ func TestSandboxDeploymentChangesAuthenticateAndDecode(t *testing.T) {
}
return deployment.View{Provider: in.Provider}, nil
}
maintain := func(_ context.Context, in store.SandboxResetRequest) (deployment.View, error) {
maintain := func(_ context.Context, in deployment.ResetRequest) (deployment.View, error) {
resets++
if in.ExpectedGeneration != 2 {
t.Fatal("generation was lost")
Expand Down Expand Up @@ -73,36 +73,45 @@ func TestSandboxDeploymentChangesAuthenticateAndDecode(t *testing.T) {
}
}

// The deployment writer, which the reset handlers use, and the Session writer,
// which admission uses, report the deployment errors alike.
func TestSandboxMutationErrorsExposeOnlyTypedCoreFacts(t *testing.T) {
writers := map[string]func(http.ResponseWriter, *http.Request, error){"deployment": writeDeploymentError, "store": writeStoreError}
for _, tc := range []struct {
err error
code string
status int
details string
err error
code string
status int
details string
admission bool
}{
{&deployment.GenerationStaleError{CurrentGeneration: 8}, "sandbox_generation_stale", 409, `"current_generation":8`},
{&deployment.ResetRequiredError{CurrentProvider: "docker", RequestedProvider: "e2b"}, "sandbox_reset_required", 409, `"requested_provider":"e2b"`},
{&deployment.ResetRequiredError{CurrentProvider: "e2b", RequestedProvider: "e2b"}, "sandbox_reset_required", 409, `"current_provider":"e2b"`},
{&store.SandboxInUseError{Resources: deployment.Resources{Allocations: 2, Pending: 1}}, "sandbox_in_use", 409, `"allocations":2`},
{deployment.ErrResetInProgress, "sandbox_reset_in_progress", 409, ""},
{store.ErrSandboxResetAdmission, "sandbox_reset_in_progress", 503, ""},
{&deployment.GenerationStaleError{CurrentGeneration: 8}, "sandbox_generation_stale", 409, `"current_generation":8`, false},
{&deployment.ResetRequiredError{CurrentProvider: "docker", RequestedProvider: "e2b"}, "sandbox_reset_required", 409, `"requested_provider":"e2b"`, false},
{&deployment.ResetRequiredError{CurrentProvider: "e2b", RequestedProvider: "e2b"}, "sandbox_reset_required", 409, `"current_provider":"e2b"`, false},
{&deployment.InUseError{Resources: deployment.Resources{Allocations: 2, Pending: 1}}, "sandbox_in_use", 409, `"allocations":2`, false},
{deployment.ErrResetInProgress, "sandbox_reset_in_progress", 409, "", false},
{store.ErrSandboxResetAdmission, "sandbox_reset_in_progress", 503, "", true},
} {
for _, core := range []bool{false, true} {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { writeStoreError(w, r, tc.err) }))
if core {
handler = coreErrorResponses(handler)
for name, write := range writers {
if tc.admission && name != "store" {
continue
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest("POST", "/test", nil))
body := response.Body.String()
if response.Code != tc.status || !strings.Contains(body, `"code":"`+tc.code+`"`) {
t.Fatal(body)
}
if core && tc.details != "" && !strings.Contains(body, tc.details) {
t.Fatal("typed detail missing", body)
}
if !core && strings.Contains(body, `"details"`) {
t.Fatal("Core facts escaped their router", body)
for _, core := range []bool{false, true} {
handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { write(w, r, tc.err) }))
if core {
handler = coreErrorResponses(handler)
}
response := httptest.NewRecorder()
handler.ServeHTTP(response, httptest.NewRequest("POST", "/test", nil))
body := response.Body.String()
if response.Code != tc.status || !strings.Contains(body, `"code":"`+tc.code+`"`) {
t.Fatal(name, body)
}
if core && tc.details != "" && !strings.Contains(body, tc.details) {
t.Fatal("typed detail missing", body)
}
if !core && strings.Contains(body, `"details"`) {
t.Fatal("Core facts escaped their router", body)
}
}
}
}
Expand Down
Loading
Loading