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
10 changes: 1 addition & 9 deletions docs/api/agents.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,9 @@ Required fields:
- `name`: non-empty string;
- `model`: model ID string or an object with a non-empty `id`.

The object model form also preserves supported `effort`, `speed`, and
`inference_geo` values.
The object model form also preserves supported `effort` and `speed` values.
`effort` accepts either a level string such as `"high"` or the tagged object
`{"type":"high"}`; responses use the tagged object form.
An explicit non-empty `inference_geo` is forwarded on every working and outcome
grader request. On Agent update, `model` is whole-object replacement for this
field: omitting `inference_geo` clears a previous pin.
Optional collection and metadata fields may be omitted or supplied with their
documented array/object shape; explicit `null` is not a create-time default.

Expand All @@ -62,10 +58,6 @@ Creating a Session expands those pins into the full immutable definitions
returned in `session.agent.multiagent.agents`; child Threads will execute those
Session-owned snapshots rather than re-resolving Agent resources.
Archived, missing, duplicate, and nested coordinator references are rejected.
If the coordinator pins `model.inference_geo`, every independently referenced
Agent must pin the same value; if the coordinator leaves it unset, every member
must also leave it unset. A model change that would violate this invariant must
replace or clear the roster in the same Agent update.
Ordinary roster entries can execute as persistent child Session Threads with
independent context, events, usage, and Workflow state. See the
[multi-agent guide](../guides/multi-agent.md) for an end-to-end example and
Expand Down
21 changes: 10 additions & 11 deletions docs/api/sessions.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,9 @@ Session-local overrides:

Overrides replace model, system, tools, MCP servers, or skills for this session
only. They do not mutate or renumber the agent. A model override may change the
model ID, speed, or inference geography; effort remains an Agent-level setting
and a session override does not replace it. Overrides also apply to `self`
copies in a coordinator roster. Independently referenced Agents are unaffected,
so a geography override that would make the coordinator disagree with one of
those pinned Agents is rejected.
model ID or speed; effort remains an Agent-level setting and a session override
does not replace it. Overrides also apply to `self` copies in a coordinator
roster. Independently referenced Agents are unaffected.

For a coordinator, `session.agent.multiagent.agents` expands the Agent
resource's Version references into full immutable Agent definitions. The
Expand Down Expand Up @@ -249,12 +247,13 @@ File and Memory Store Resource objects. Ordered `vault_ids` are resolved at
creation; update-time vault replacement is rejected.
`usage` aggregates provider-reported token, prompt-cache, Web Fetch, and Web
Search counters across every Session Thread. `usage.list_cost` is calculated
from Mango's current built-in price catalog, the Web Search request rate, and
$0.08 per Session active hour, then rounded to the nearest cent for the public
monetary projection. Thread list cost excludes Session runtime. Accounting
remains exact internally, and model-request admission checks the shared ceiling
before every request; an already in-flight request may take the Session over its
limit.
from Mango's current built-in price catalog, provider-reported execution facts
that affect that catalog's rates, the Web Search request rate, and $0.08 per
Session active hour, then rounded to the nearest cent for the public monetary
projection. Provider routing is not Agent configuration. Thread list cost
excludes Session runtime. Accounting remains exact internally, and
model-request admission checks the shared ceiling before every request; an
already in-flight request may take the Session over its limit.
Provider-reported tokens remain visible even when a response-level billing rule
makes their list cost zero, such as an unbilled Claude Fable 5 refusal.

Expand Down
2 changes: 1 addition & 1 deletion docs/capabilities.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ and service test suites.

| Capability | Status | Supported scope and important constraints |
| --- | --- | --- |
| Agents and Versions | Supported | Create, get, list, update, immutable Version history, archive, filters, and pagination. Model ID, effort, speed, and `inference_geo` reach working and grader requests. |
| Agents and Versions | Supported | Create, get, list, update, immutable Version history, archive, filters, and pagination. Model ID, effort, and speed reach working and grader requests. Provider routing policy remains outside the Agent contract. |
| Environments | Supported | Cloud and self-hosted lifecycle, package configuration, limited-network declarations, filters, and pagination. Package execution requires a capable sandbox; limited egress is currently enforced only by OpenSandbox. |
| Sessions | Supported | Create from immutable Agent snapshots, get/list/update/archive/delete, metadata, filters, exact shared public-list-cost budgets, usage, timing, and resource projections. Deletion fences admission and durably releases the Workflow and sandbox. |
| Events and client actions | Limited | System context, messages, thinking, tool events, confirmation/custom/self-hosted result barriers, outcomes, retries, interrupts, and the budget-boundary `session.usage`/`budget_reached` idle sequence are implemented. File-backed message documents are limited to bounded UTF-8 text; File-sourced images and File documents in tool results are not supported. |
Expand Down
2 changes: 0 additions & 2 deletions docs/guides/multi-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,6 @@ an Advisor, and persistent follow-up, see
tools. The deterministic local
model is useful for platform smoke tests but does not make open-ended
delegation decisions.
- Keep every roster Agent on the same `inference_geo` value, or leave it unset
everywhere.

The examples use the local API at `http://localhost:8080`.

Expand Down
10 changes: 10 additions & 0 deletions docs/provenance.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,16 @@ release is never an automatic roadmap.
outbound endpoint requires. Tests that exercise Mango through an Anthropic
SDK are optional research evidence; raw HTTP and OpenAPI tests define Mango's
transport contract.
- Claude Managed Agents' agent-level `inference_geo` and the public
[Claude data-residency design](https://platform.claude.com/docs/en/manage-claude/data-residency)
prompted a focused review on 2026-08-27. Mango rejected request-time
geography from its Agent and Session model configuration: it is a hosted
provider routing policy, other model platforms express placement through
different endpoints or deployment resources, and Mango's replaceable model
boundary cannot enforce a portable meaning for it. Operators select and
govern the configured model endpoint outside the Agent contract. The current
Anthropic adapter reads a provider-reported response region only as an
internal list-cost input; it never sends a geography request field.

## Built-in Agent tools

Expand Down
18 changes: 8 additions & 10 deletions internal/agentruntime/agentcore.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,11 +133,10 @@ func (a *AgentCore) Run(ctx context.Context, req RunRequest, sink EventSink) (Ru
messageID := a.ids.NewID(domain.PrefixEvent)
started := false
resp, err = a.client.CreateMessageStream(ctx, model.Request{
Model: req.AgentSnapshot.Model.ID,
InferenceGeo: req.AgentSnapshot.Model.InferenceGeo,
System: system,
Messages: messages,
Tools: toolSchemas,
Model: req.AgentSnapshot.Model.ID,
System: system,
Messages: messages,
Tools: toolSchemas,
}, func(index int, text string) {
if !started {
previewer.PreviewStart(messageID, domain.EvAgentMessage)
Expand Down Expand Up @@ -172,11 +171,10 @@ func (a *AgentCore) Run(ctx context.Context, req RunRequest, sink EventSink) (Ru
}
} else {
resp, err = a.client.CreateMessage(ctx, model.Request{
Model: req.AgentSnapshot.Model.ID,
InferenceGeo: req.AgentSnapshot.Model.InferenceGeo,
System: system,
Messages: messages,
Tools: toolSchemas,
Model: req.AgentSnapshot.Model.ID,
System: system,
Messages: messages,
Tools: toolSchemas,
})
if err != nil {
return RunOutcome{}, err
Expand Down
7 changes: 3 additions & 4 deletions internal/agentruntime/multiagent.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,10 +143,9 @@ func AdvisorRequest(
return model.Request{}, fmt.Errorf("encode advisor context: %w", err)
}
return model.Request{
Model: advisorModel,
InferenceGeo: executor.InferenceGeo,
System: advisorReviewerSystem,
MaxTokens: advisorMaxTokens,
Model: advisorModel,
System: advisorReviewerSystem,
MaxTokens: advisorMaxTokens,
Messages: []domain.Message{{
Role: domain.RoleUser,
Content: []domain.ContentBlock{{
Expand Down
3 changes: 1 addition & 2 deletions internal/agentruntime/multiagent_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ func TestAdvisorRequestQuotesExecutorContextWithoutReplayingReasoning(t *testing
require.Equal(t, false, schema.InputSchema["additionalProperties"])

executor := model.Request{
Model: "executor-model", InferenceGeo: "us", System: "executor system",
Model: "executor-model", System: "executor system",
Tools: []model.ToolSchema{{
Name: "read", Description: "Read a file.",
InputSchema: map[string]any{"type": "object"},
Expand All @@ -49,7 +49,6 @@ func TestAdvisorRequestQuotesExecutorContextWithoutReplayingReasoning(t *testing
)
require.NoError(t, err)
require.Equal(t, "advisor-model", request.Model)
require.Equal(t, "us", request.InferenceGeo)
require.Empty(t, request.Tools)
require.Equal(t, 2048, request.MaxTokens)
require.Len(t, request.Messages, 1)
Expand Down
12 changes: 0 additions & 12 deletions internal/app/agent_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,6 @@ func (s *AgentService) Update(ctx context.Context, id string, patch domain.Agent
// version used to perform semantic no-op detection.
next.Multiagent = next.Multiagent.RebindAgentVersion(cur.ID, cur.Version+1)
}
if patch.Model != nil && patch.Multiagent == nil &&
next.Model.InferenceGeo != cur.Model.InferenceGeo &&
cur.Multiagent.HasExternalAgent(cur.ID) {
return domain.Agent{}, false, domain.Validation(
"model.inference_geo must match every independently referenced multiagent roster member",
)
}
if changed && next.Multiagent != nil && !next.Multiagent.IsResolved() {
return domain.Agent{}, false, domain.Validation(
"legacy multiagent configuration must be replaced before updating the Agent",
Expand Down Expand Up @@ -306,11 +299,6 @@ func (s *AgentService) resolveMultiagent(
return nil, domain.Validation("multiagent references are limited to one coordinator level")
}
}
if target.Model.InferenceGeo != ownerModel.InferenceGeo {
return nil, domain.Validation(
"model.inference_geo must match every multiagent roster member",
)
}
if _, duplicate := seen[target.ID]; duplicate {
return nil, domain.Validation("multiagent.agents must reference distinct agents")
}
Expand Down
64 changes: 0 additions & 64 deletions internal/app/agent_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -260,70 +260,6 @@ func TestAgentService_MultiagentPinsLatestAndRebindsSelf(t *testing.T) {
}
}

func TestAgentService_MultiagentInferenceGeoMustMatch(t *testing.T) {
s := newAgentService(t)
ctx := context.Background()
globalPeer, err := s.Create(ctx, domain.Agent{
Name: "global peer", Model: domain.Model{ID: "m", InferenceGeo: "global"},
})
if err != nil {
t.Fatal(err)
}
usPeer, err := s.Create(ctx, domain.Agent{
Name: "US peer", Model: domain.Model{ID: "m", InferenceGeo: "us"},
})
if err != nil {
t.Fatal(err)
}
roster := func(id string) *domain.Multiagent {
return &domain.Multiagent{Type: "coordinator", Agents: []domain.AgentReference{{
Type: "agent", ID: id,
}}}
}
if _, err := s.Create(ctx, domain.Agent{
Name: "mismatch", Model: domain.Model{ID: "m", InferenceGeo: "us"},
Multiagent: roster(globalPeer.ID),
}); err == nil {
t.Fatal("created a coordinator whose inference_geo differs from its roster")
}

coordinator, err := s.Create(ctx, domain.Agent{
Name: "coordinator", Model: domain.Model{ID: "m", InferenceGeo: "global"},
Multiagent: roster(globalPeer.ID),
})
if err != nil {
t.Fatal(err)
}
usModel := domain.Model{ID: "m", InferenceGeo: "us"}
if _, err := s.Update(ctx, coordinator.ID, domain.AgentPatch{Model: &usModel}); err == nil {
t.Fatal("changed coordinator inference_geo without replacing its external roster")
}
updated, err := s.Update(ctx, coordinator.ID, domain.AgentPatch{
Model: &usModel, Multiagent: &domain.NullableMultiagent{Value: roster(usPeer.ID)},
})
if err != nil {
t.Fatalf("replace model and roster atomically: %v", err)
}
if updated.Model.InferenceGeo != "us" || updated.Multiagent.Agents[0].ID != usPeer.ID {
t.Fatalf("updated coordinator = %#v", updated)
}

selfOnly, err := s.Create(ctx, domain.Agent{
Name: "self", Model: domain.Model{ID: "m", InferenceGeo: "global"},
Multiagent: &domain.Multiagent{Type: "coordinator", Agents: []domain.AgentReference{{Type: "self"}}},
})
if err != nil {
t.Fatal(err)
}
selfOnly, err = s.Update(ctx, selfOnly.ID, domain.AgentPatch{Model: &usModel})
if err != nil {
t.Fatalf("self copies should inherit the coordinator override: %v", err)
}
if selfOnly.Model.InferenceGeo != "us" || selfOnly.Multiagent.Agents[0].Version != 2 {
t.Fatalf("updated self coordinator = %#v", selfOnly)
}
}

func TestAgentService_MultiagentRejectsInvalidReferences(t *testing.T) {
s := newAgentService(t)
ctx := context.Background()
Expand Down
6 changes: 0 additions & 6 deletions internal/controlplane/session_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -203,12 +203,6 @@ func (s *SessionService) Create(
if input.Overrides != nil {
snapshot = agent.WithOverrides(*input.Overrides)
}
if snapshot.Model.InferenceGeo != agent.Model.InferenceGeo &&
agent.Multiagent.HasExternalAgent(agent.ID) {
return domain.Session{}, domain.Validation(
"agent override model.inference_geo must match every independently referenced multiagent roster member",
)
}
snapshot.Skills, err = app.ResolveAgentSkillReferences(
ctx,
s.skillRef,
Expand Down
13 changes: 3 additions & 10 deletions internal/domain/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@ import (
)

type Model struct {
ID string
Effort string
Speed string
InferenceGeo string
ID string
Effort string
Speed string
// EffortExplicit and SpeedExplicit distinguish an explicit Agent setting
// from the Mango defaults echoed in the resolved resource. The
// Messages adapter uses this distinction to avoid sending preview fields to
Expand Down Expand Up @@ -54,9 +53,6 @@ func ValidateModel(model Model) error {
default:
return Validation("model speed must be standard or fast")
}
if model.InferenceGeo != "" && strings.TrimSpace(model.InferenceGeo) == "" {
return Validation("model inference_geo must be a non-empty string")
}
return nil
}

Expand Down Expand Up @@ -434,9 +430,6 @@ func (a Agent) SessionSnapshotJSON() map[string]any {
if a.Model.Speed != "" {
model["speed"] = a.Model.Speed
}
if a.Model.InferenceGeo != "" {
model["inference_geo"] = a.Model.InferenceGeo
}
system, description := "", ""
if a.System != nil {
system = *a.System
Expand Down
36 changes: 18 additions & 18 deletions internal/domain/budget.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,10 @@ func RuntimeListCostNanoUSD(activeSeconds float64) int64 {
}

type modelListPrice struct {
inputPerToken int64
outputPerToken int64
geoSurcharge bool
fastEligible bool
inputPerToken int64
outputPerToken int64
usRegionSurcharge bool
fastEligible bool
}

var datedModelSuffix = regexp.MustCompile(`^(.*?)(?:-[0-9]{8})?$`)
Expand Down Expand Up @@ -151,18 +151,18 @@ func ModelUsageListCostNanoUSDAt(
// Fast mode for currently supported Opus models is $10/$50 per MTok.
inputRate, outputRate = 10_000, 50_000
}
geoNumerator, geoDenominator := int64(1), int64(1)
if strings.EqualFold(model.InferenceGeo, "us") && price.geoSurcharge {
geoNumerator, geoDenominator = 11, 10
regionNumerator, regionDenominator := int64(1), int64(1)
if strings.EqualFold(usage.ProviderRegion, "us") && price.usRegionSurcharge {
regionNumerator, regionDenominator = 11, 10
}
scaled := func(tokens, rate, numerator, denominator int64) int64 {
return tokens * rate * numerator / denominator
}
cost := scaled(usage.InputTokens, inputRate, geoNumerator, geoDenominator)
cost += scaled(usage.OutputTokens, outputRate, geoNumerator, geoDenominator)
cost += scaled(usage.CacheCreation.Ephemeral5mInputTokens, inputRate*5, geoNumerator, geoDenominator*4)
cost += scaled(usage.CacheCreation.Ephemeral1hInputTokens, inputRate*2, geoNumerator, geoDenominator)
cost += scaled(usage.CacheReadInputTokens, inputRate, geoNumerator, geoDenominator*10)
cost := scaled(usage.InputTokens, inputRate, regionNumerator, regionDenominator)
cost += scaled(usage.OutputTokens, outputRate, regionNumerator, regionDenominator)
cost += scaled(usage.CacheCreation.Ephemeral5mInputTokens, inputRate*5, regionNumerator, regionDenominator*4)
cost += scaled(usage.CacheCreation.Ephemeral1hInputTokens, inputRate*2, regionNumerator, regionDenominator)
cost += scaled(usage.CacheReadInputTokens, inputRate, regionNumerator, regionDenominator*10)
cost += usage.ServerToolUse.WebSearchRequests * webSearchRequestNanoUSD
return cost, nil
}
Expand Down Expand Up @@ -192,19 +192,19 @@ func anthropicModelListPrice(id string) (modelListPrice, bool) {
id = canonicalAnthropicModelID(id)
switch id {
case "claude-fable-5", "claude-mythos-5":
return modelListPrice{inputPerToken: 10_000, outputPerToken: 50_000, geoSurcharge: true}, true
return modelListPrice{inputPerToken: 10_000, outputPerToken: 50_000, usRegionSurcharge: true}, true
case "claude-opus-5":
return modelListPrice{inputPerToken: 5_000, outputPerToken: 25_000, geoSurcharge: true, fastEligible: true}, true
return modelListPrice{inputPerToken: 5_000, outputPerToken: 25_000, usRegionSurcharge: true, fastEligible: true}, true
case "claude-sonnet-5":
return modelListPrice{inputPerToken: 2_000, outputPerToken: 10_000, geoSurcharge: true}, true
return modelListPrice{inputPerToken: 2_000, outputPerToken: 10_000, usRegionSurcharge: true}, true
case "claude-opus-4-8":
return modelListPrice{inputPerToken: 5_000, outputPerToken: 25_000, geoSurcharge: true, fastEligible: true}, true
return modelListPrice{inputPerToken: 5_000, outputPerToken: 25_000, usRegionSurcharge: true, fastEligible: true}, true
case "claude-opus-4-7", "claude-opus-4-6", "claude-opus-4-5":
return modelListPrice{inputPerToken: 5_000, outputPerToken: 25_000, geoSurcharge: id == "claude-opus-4-7" || id == "claude-opus-4-6"}, true
return modelListPrice{inputPerToken: 5_000, outputPerToken: 25_000, usRegionSurcharge: id == "claude-opus-4-7" || id == "claude-opus-4-6"}, true
case "claude-opus-4-1", "claude-opus-4":
return modelListPrice{inputPerToken: 15_000, outputPerToken: 75_000}, true
case "claude-sonnet-4-6":
return modelListPrice{inputPerToken: 3_000, outputPerToken: 15_000, geoSurcharge: true}, true
return modelListPrice{inputPerToken: 3_000, outputPerToken: 15_000, usRegionSurcharge: true}, true
case "claude-sonnet-4-5", "claude-sonnet-4":
return modelListPrice{inputPerToken: 3_000, outputPerToken: 15_000}, true
case "claude-haiku-4-5":
Expand Down
10 changes: 9 additions & 1 deletion internal/domain/budget_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,10 @@ func TestModelUsageListCostNanoUSD(t *testing.T) {
CacheReadInputTokens: 400,
ServerToolUse: ServerToolUsage{WebSearchRequests: 2, WebFetchRequests: 7},
Speed: "standard",
ProviderRegion: "us",
}
cost, err := ModelUsageListCostNanoUSD(
Model{ID: "claude-opus-4-8-20260801", InferenceGeo: "us"},
Model{ID: "claude-opus-4-8-20260801"},
usage,
)
if err != nil {
Expand All @@ -29,6 +30,13 @@ func TestModelUsageListCostNanoUSD(t *testing.T) {
if amount := MonetaryAmountJSON(cost)["amount"]; amount != "3" {
t.Fatalf("rounded monetary amount = %v, want 3 cents", amount)
}
usage.ProviderRegion = ""
baseCost, err := ModelUsageListCostNanoUSD(
Model{ID: "claude-opus-4-8-20260801"}, usage,
)
if err != nil || baseCost != 31_950_000 {
t.Fatalf("base list cost = %d nanoUSD, err=%v", baseCost, err)
}
}

func TestModelUsageListCostRejectsUnknownAndUnsupportedReportedFastMode(t *testing.T) {
Expand Down
Loading