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
6 changes: 4 additions & 2 deletions services/core/IMPLEMENTATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ These are the code-level rules of `services/core` that no contract states. Contr

`internal/persistence/postgres/pgunit` owns Core's PostgreSQL transaction and execution-lease mechanics: pooled read-write and snapshot transactions, the lease's dedicated connection and its gate, the ownership check, the cancellation fence, close, and the execution deadline. Persistence code runs every transaction through it, and nothing outside `persistence` and `store` imports it. `internal/persistence/postgres/pgtest` is test support: it opens the dedicated test database under the `oac_*_tests` guard, applies the migrations, and creates isolated databases for database-wide state such as the execution lease. Only test files import it.

Shared vocabulary has one owner each, and domains use it rather than copy it. `internal/environmentconfig` owns Environment setup, Skills, Plugins and initial files with their validation and public metadata; `Setup.Validate` checks requested configuration, where a Skill may be an unresolved reference, and `Setup.ValidateInstalled` checks frozen, installable configuration. `internal/skills` owns `ParseVersion`, the canonical positive decimal Skill version. `internal/metadata` owns the metadata rules: `Validate` for the pair, key and value limits and U+0000, `ValidateStorable` for U+0000 alone, and `Encode` with its 64 KiB bound. `internal/jsonobject` owns `Normalize`, the stable encoding of stored JSON objects that snapshots and retry identities compare. These packages import no persistence.

`store` is transitional. `store.New` builds a pooled Store, and `store.NewExecution` takes the lease and builds the execution writer on it. An execution-only operation on a pooled Store fails with `store.ErrExecutionAuthority`. New adapters do not copy that check: their execution repositories require a `*pgunit.Lease` at construction, their public repositories expose no execution operation, and the check goes away with `store`.

## Request handling

Every Agents API JSON route reads its body through `readJSONObject` before decoding, validation or lookup. The gate requires a JSON Content-Type, applies the route's body limit and rejects invalid UTF-8, malformed JSON (including unpaired surrogate escapes), repeated keys and non-object roots with the official messages; an empty body or `null` becomes `{}`. DELETE, multipart, Core extension and internal routes keep their own readers. Member names match exactly: decode request objects with `decodeInputObject`, or check `inexactMember` before another decoder, so `encoding/json` never matches a case variant.

Report a validation failure that has official evidence through the typed field error, which emits `invalid_request_error` with the observed param and message; keep other local codes until their official fields are sampled. Saved and inline Agent configuration pass one path-tracking validator of the pinned shapes before their parsers and harness admission; do not grow it into a JSON Schema engine. A malformed path identifier must produce exactly the response of a well-formed missing one on that route, including for invalid bodies, queries and storage availability: resolve it to the never-assigned maximum UUID and let the missing path run, or reject it directly only where the lookup is the next check. An `after` cursor that does not resolve inside its already resolved parent, malformed ones included, returns that list family's observed error, and foreign and missing cursors stay identical. U+0000 is rejected explicitly only in metadata (`metadata.<key>`); other stored strings rely on the PostgreSQL error mapping, so keep each request's writes in one transaction.
Report a validation failure that has official evidence through the typed field error, which emits `invalid_request_error` with the observed param and message; keep other local codes until their official fields are sampled. Saved and inline Agent configuration pass one path-tracking validator of the pinned shapes before their parsers and harness admission; do not grow it into a JSON Schema engine. A malformed path identifier must produce exactly the response of a well-formed missing one on that route, including for invalid bodies, queries and storage availability: resolve it to the never-assigned maximum UUID and let the missing path run, or reject it directly only where the lookup is the next check. An `after` cursor that does not resolve inside its already resolved parent, malformed ones included, returns that list family's observed error, and foreign and missing cursors stay identical. U+0000 is rejected explicitly only in metadata (`metadata.<key>`), by the `metadata` package; other stored strings rely on the PostgreSQL error mapping, so keep each request's writes in one transaction.

List queries reuse the shared parser and error serializer while keeping each family's limit bounds and error fields. The Environment Files list keeps its own path and cursor parsing but follows the same unknown-key and duplicate-key rules, and still rejects malformed query encoding that the shared lists drop. Change page bounds, cursor ownership or parent lookup order only with evidence for that family, and never reproduce an observed upstream server failure as compatibility behavior.

Expand Down Expand Up @@ -84,7 +86,7 @@ Session status and last activity use the public projection in [`internal/api/ses

## Agents and model providers

Reusable Agents are tenant-scoped rows independent of Session snapshots and engine bindings. The store persists caller-validated configuration without applying harness restrictions or model defaults, with internal limits of 512 KiB for configuration and 64 KiB for metadata. An update locks the Agent row while merging the supplied fields and enforcing the configuration bound, then commits configuration, metadata and update time together, so a stale full snapshot never overwrites another update. An empty update preserves the saved fields and advances `updated_at` through the same SQL update. Deletion is one tenant-scoped `DELETE … RETURNING id`. A Session copies the saved configuration into its immutable snapshot and never looks up its source again.
Reusable Agents are tenant-scoped rows independent of Session snapshots and engine bindings. The store persists caller-validated configuration without applying harness restrictions or model defaults, with internal limits of 512 KiB for configuration and the `metadata.Encode` bound of 64 KiB for metadata. An update locks the Agent row while merging the supplied fields and enforcing the configuration bound, then commits configuration, metadata and update time together, so a stale full snapshot never overwrites another update. An empty update preserves the saved fields and advances `updated_at` through the same SQL update. Deletion is one tenant-scoped `DELETE … RETURNING id`. A Session copies the saved configuration into its immutable snapshot and never looks up its source again.

Saved execution defaults keep a model-provider bundle whole at every replacement boundary: endpoint, key, protocol and limits are never inherited separately. Agent JSON holds only the safe provider fields and an output-only configured flag; the complete bundle is encrypted separately with a tenant and Agent binding and its own purpose, and configuration and secret changes commit together under the Agent row lock. Model-only edits need no key. Merged harness, protocol and limits are validated without reading keys. Session creation reads safe defaults and ciphertext in one snapshot, and a complete Session override does not decrypt the inherited bundle. The resolved bundle is frozen in an encrypted Session-owned row, and dispatch fails closed when that snapshot is missing or cannot be decrypted; later Agent edits, default changes, restarts and suspension never resolve it again.

Expand Down
3 changes: 2 additions & 1 deletion services/core/internal/api/configuration.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"

v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/metadata"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store"
"github.com/google/uuid"
)
Expand All @@ -20,7 +21,7 @@ func resolve(input sessionRequest, tenant, key string, saved *v1.SavedAgent) (js
if input.Environment == nil || (input.Environment.Type != "none" && input.Environment.Type != "self_hosted" && input.Environment.Type != "openai_hosted") {
return nil, errors.New("Unsupported environment type.")
}
if err := validateMetadata(input.Metadata); err != nil {
if err := metadataFieldError(metadata.Validate(input.Metadata)); err != nil {
return nil, err
}
agent, err := resolveSessionAgent(input, saved)
Expand Down
9 changes: 5 additions & 4 deletions services/core/internal/api/environment_plugins.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@ import (
"encoding/json"

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

func decodeEnvironmentPlugins(raw json.RawMessage) ([]store.EnvironmentPlugin, error) {
func decodeEnvironmentPlugins(raw json.RawMessage) ([]environmentconfig.Plugin, error) {
if len(raw) == 0 {
return nil, nil
}
var entries []json.RawMessage
if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 {
return nil, store.ErrInvalidInput
}
result := make([]store.EnvironmentPlugin, 0, len(entries))
result := make([]environmentconfig.Plugin, 0, len(entries))
for _, entry := range entries {
var input struct {
Type string `json:"type"`
Expand All @@ -30,9 +31,9 @@ func decodeEnvironmentPlugins(raw json.RawMessage) ([]store.EnvironmentPlugin, e
if err != nil {
return nil, err
}
result = append(result, store.EnvironmentPlugin{Metadata: agentplugin.Metadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body})
result = append(result, environmentconfig.Plugin{Metadata: agentplugin.Metadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body})
}
return result, store.ValidateEnvironmentPlugins(result)
return result, environmentconfig.ValidatePlugins(result)
}

func pluginResponse(plugins []agentplugin.Metadata) []json.RawMessage {
Expand Down
9 changes: 5 additions & 4 deletions services/core/internal/api/environment_setup.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"encoding/json"

v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store"
)

Expand All @@ -23,8 +24,8 @@ func rejectSystemPackages(raw json.RawMessage) error {
return nil
}

func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.EnvironmentSetup, error) {
var result store.EnvironmentSetup
func decodeEnvironmentSetup(fields map[string]json.RawMessage) (environmentconfig.Setup, error) {
var result environmentconfig.Setup
if err := rejectSystemPackages(fields["packages"]); err != nil {
return result, err
}
Expand Down Expand Up @@ -54,7 +55,7 @@ func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.Environmen
if decodeInputObject(command, &input, "command", "cwd") != nil || input.Command == nil {
return result, store.ErrInvalidInput
}
step := store.SetupCommand{Command: *input.Command}
step := environmentconfig.SetupCommand{Command: *input.Command}
if input.CWD != nil {
if *input.CWD == "" {
return result, store.ErrInvalidInput
Expand Down Expand Up @@ -113,7 +114,7 @@ func decodeEnvironmentSetup(fields map[string]json.RawMessage) (store.Environmen
}

func packageMetadata(packages *v1.EnvironmentPackages) v1.EnvironmentPackagesResponse {
value := store.EnvironmentSetup{}
value := environmentconfig.Setup{}
if packages != nil {
value.Packages = *packages
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"reflect"
"testing"

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

Expand All @@ -25,7 +26,7 @@ func TestSkillReferenceNullableSelectorAdmissionAndTemplateProjection(t *testing
if err != nil || !template.SetSkills || len(template.Initialization.Skills) != 1 {
t.Fatalf("template admission: %+v %v", template, err)
}
want := store.EnvironmentSkill{Metadata: store.EnvironmentSkillMetadata{Type: "skill_reference", SkillID: "skill-owned", Version: test.selector}}
want := environmentconfig.Skill{Metadata: environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: "skill-owned", Version: test.selector}}
if !reflect.DeepEqual(template.Initialization.Skills[0], want) {
t.Fatalf("unresolved selector changed: %+v", template.Initialization.Skills[0])
}
Expand Down Expand Up @@ -61,8 +62,8 @@ func TestSkillReferenceNullDoesNotWidenOtherSelectors(t *testing.T) {
}

func TestInstalledSkillReferenceRequiresConcreteVersion(t *testing.T) {
metadata := store.EnvironmentSkillMetadata{Type: "skill_reference", SkillID: "skill-owned", Version: "2", Name: "proof", Description: "A proof."}
public := skillResponse([]store.EnvironmentSkillMetadata{metadata})
metadata := environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: "skill-owned", Version: "2", Name: "proof", Description: "A proof."}
public := skillResponse([]environmentconfig.SkillMetadata{metadata})
var reference map[string]any
if len(public) != 1 || json.Unmarshal(public[0], &reference) != nil {
t.Fatalf("installed projection: %s", public)
Expand Down
23 changes: 12 additions & 11 deletions services/core/internal/api/environment_skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@ import (
"bytes"
"encoding/json"

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

func decodeEnvironmentSkills(raw json.RawMessage) ([]store.EnvironmentSkill, error) {
func decodeEnvironmentSkills(raw json.RawMessage) ([]environmentconfig.Skill, error) {
if len(raw) == 0 {
return nil, nil
}
var entries []json.RawMessage
if json.Unmarshal(raw, &entries) != nil || len(entries) > 50 {
return nil, store.ErrInvalidInput
}
result := make([]store.EnvironmentSkill, 0, len(entries))
result := make([]environmentconfig.Skill, 0, len(entries))
for _, entry := range entries {
var discriminator struct {
Type string `json:"type"`
Expand All @@ -32,13 +33,13 @@ func decodeEnvironmentSkills(raw json.RawMessage) ([]store.EnvironmentSkill, err
if decodeInputObject(entry, &reference, "type", "skill_id", "version") != nil {
return nil, store.ErrInvalidInput
}
metadata := store.EnvironmentSkillMetadata{Type: reference.Type, SkillID: reference.SkillID}
metadata := environmentconfig.SkillMetadata{Type: reference.Type, SkillID: reference.SkillID}
if len(reference.Version) > 0 && !bytes.Equal(bytes.TrimSpace(reference.Version), []byte("null")) {
if json.Unmarshal(reference.Version, &metadata.Version) != nil || metadata.Version == "" {
return nil, store.ErrInvalidInput
}
}
result = append(result, store.EnvironmentSkill{Metadata: metadata})
result = append(result, environmentconfig.Skill{Metadata: metadata})
continue
}
var input struct {
Expand All @@ -54,20 +55,20 @@ func decodeEnvironmentSkills(raw json.RawMessage) ([]store.EnvironmentSkill, err
if err != nil {
return nil, err
}
result = append(result, store.EnvironmentSkill{Metadata: store.EnvironmentSkillMetadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body})
result = append(result, environmentconfig.Skill{Metadata: environmentconfig.SkillMetadata{Type: input.Type, Name: input.Name, Description: input.Description}, Archive: body})
}
return result, store.ValidateEnvironmentSkills(result)
return result, environmentconfig.ValidateSkills(result)
}

func skillResponse(skills []store.EnvironmentSkillMetadata) []json.RawMessage {
func skillResponse(skills []environmentconfig.SkillMetadata) []json.RawMessage {
result := make([]json.RawMessage, 0, len(skills))
for _, skill := range skills {
var projection any = skill
if skill.Type == "skill_reference" && skill.Version == "" {
projection = struct {
store.EnvironmentSkillMetadata
environmentconfig.SkillMetadata
Version *string `json:"version"`
}{EnvironmentSkillMetadata: skill}
}{SkillMetadata: skill}
}
raw, _ := json.Marshal(projection)
result = append(result, raw)
Expand All @@ -85,8 +86,8 @@ func storedSkills(raw json.RawMessage) ([]json.RawMessage, error) {
}
seen := map[string]bool{}
for _, entry := range entries {
var metadata store.EnvironmentSkillMetadata
if decodeInputObject(entry, &metadata, "type", "name", "description", "skill_id", "version") != nil || store.ValidateInstalledSkillMetadata(metadata) != nil || seen[metadata.Name] {
var metadata environmentconfig.SkillMetadata
if decodeInputObject(entry, &metadata, "type", "name", "description", "skill_id", "version") != nil || metadata.ValidateInstalled() != nil || seen[metadata.Name] {
return nil, store.ErrInvalidInput
}
seen[metadata.Name] = true
Expand Down
4 changes: 2 additions & 2 deletions services/core/internal/api/environment_skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"encoding/json"
"testing"

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

func skillInput(t *testing.T, body string) json.RawMessage {
Expand All @@ -32,7 +32,7 @@ func skillInput(t *testing.T, body string) json.RawMessage {
}

func TestSkillReferenceParsingInheritanceAndReplacement(t *testing.T) {
lookup := &templateLookupStore{network: "enabled", skills: []store.EnvironmentSkill{{Metadata: store.EnvironmentSkillMetadata{Type: "skill_reference", SkillID: "skill-template", Version: "latest"}}}}
lookup := &templateLookupStore{network: "enabled", skills: []environmentconfig.Skill{{Metadata: environmentconfig.SkillMetadata{Type: "skill_reference", SkillID: "skill-template", Version: "latest"}}}}
h := templateHandler(t, lookup.ResolveEnvironmentTemplate)
for _, fields := range []string{"", `,"skills":[]`, `,"skills":[{"type":"skill_reference","skill_id":"skill-override","version":"2"}]`} {
var decoded decodedSessionRequest
Expand Down
3 changes: 2 additions & 1 deletion services/core/internal/api/environment_templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@ import (
"unicode/utf8"

v1 "github.com/MiniMax-AI/OpenAgentCore/contracts/agents-api/v1"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/environmentconfig"
"github.com/MiniMax-AI/OpenAgentCore/services/core/internal/store"
"github.com/go-chi/chi/v5"
)

// EnvironmentTemplates manages Environment Templates. ResolveEnvironmentTemplate
// reads a Template with its initial files for Session creation.
type EnvironmentTemplates interface {
ResolveEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, []store.InitialFile, error)
ResolveEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, []environmentconfig.InitialFile, error)
CreateEnvironmentTemplate(context.Context, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error)
GetEnvironmentTemplate(context.Context, string, string) (store.EnvironmentTemplate, error)
UpdateEnvironmentTemplate(context.Context, string, string, store.EnvironmentTemplateInput) (store.EnvironmentTemplate, error)
Expand Down
Loading