diff --git a/a2asrv/auditlogger/auditlogger.go b/a2asrv/auditlogger/auditlogger.go new file mode 100644 index 00000000..11587b99 --- /dev/null +++ b/a2asrv/auditlogger/auditlogger.go @@ -0,0 +1,208 @@ +// Copyright 2026 The A2A Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package auditlogger provides a server-side CallInterceptor that records +// structured audit events for every A2A protocol method invocation. +// +// The audit event schema aligns with IBM SMF Type 110 (CICS transaction audit) +// and Type 80 (RACF access audit) — recording who called what, when, with what +// result, and at what cost. +package auditlogger + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "sync" + "time" + + "github.com/a2aproject/a2a-go/v2/a2asrv" +) + +// AuditEvent represents a single auditable A2A protocol invocation. +type AuditEvent struct { + // Timestamp is when the request was received. + Timestamp time.Time `json:"timestamp"` + // AgentID is the identifier of the calling agent (from CallContext.User.Name). + AgentID string `json:"agent_id"` + // Method is the A2A protocol method (SendMessage, GetTask, CancelTask, etc.). + Method string `json:"method"` + // TaskID is the task identifier, if applicable. + TaskID string `json:"task_id,omitempty"` + // ContextID is the group identifier linking related tasks. + ContextID string `json:"context_id,omitempty"` + // Tenant is the tenant identifier from CallContext. + Tenant string `json:"tenant,omitempty"` + // Duration is the wall-clock time from Before to After. + Duration time.Duration `json:"duration_ns"` + // Result is "success", "error", or "cancelled". + Result string `json:"result"` + // ErrorMessage is set when Result is "error". + ErrorMessage string `json:"error_message,omitempty"` + // Extensions lists the extension URIs activated for this call. + Extensions []string `json:"extensions,omitempty"` +} + +// AuditWriter persists audit events. Implementations must be safe for +// concurrent use. +type AuditWriter interface { + // Write records an audit event to the persistence layer. It must be + // safe for concurrent use. + Write(ctx context.Context, event *AuditEvent) error +} + +// AuditLogger is a server-side [a2asrv.CallInterceptor] that records a +// structured audit event for every A2A protocol call. +// +// The zero value is not usable; use [NewAuditLogger]. +type AuditLogger struct { + a2asrv.PassthroughCallInterceptor + writer AuditWriter +} + +// NewAuditLogger creates an AuditLogger that writes events to w. +func NewAuditLogger(w AuditWriter) *AuditLogger { + return &AuditLogger{writer: w} +} + +// Before records the start time in the context. +func (a *AuditLogger) Before(ctx context.Context, callCtx *a2asrv.CallContext, _ *a2asrv.Request) (context.Context, any, error) { + return context.WithValue(ctx, startTimeKey{}, time.Now()), nil, nil +} + +// After builds and writes the audit event. +func (a *AuditLogger) After(ctx context.Context, callCtx *a2asrv.CallContext, resp *a2asrv.Response) error { + start, _ := ctx.Value(startTimeKey{}).(time.Time) + if start.IsZero() { + start = time.Now() + } + + ev := &AuditEvent{ + Timestamp: start, + Method: callCtx.Method(), + Duration: time.Since(start), + } + + if callCtx.User != nil { + ev.AgentID = callCtx.User.Name + } + ev.Tenant = callCtx.Tenant() + + exts := callCtx.Extensions() + if exts != nil { + ev.Extensions = exts.RequestedURIs() + } + + if resp != nil { + if resp.Err != nil { + if errors.Is(resp.Err, context.Canceled) { + ev.Result = "cancelled" + } else { + ev.Result = "error" + ev.ErrorMessage = resp.Err.Error() + } + } else { + ev.Result = "success" + } + } else { + ev.Result = "success" + } + + return a.writer.Write(ctx, ev) +} + +type startTimeKey struct{} + +// ── In-memory writer (for testing) ────────────────────────────────────── + +// InMemoryWriter stores audit events in memory. Useful for tests and +// low-volume deployments. Not suitable for production — events are lost on +// restart. +type InMemoryWriter struct { + mu sync.Mutex + events []*AuditEvent +} + +// NewInMemoryWriter creates an InMemoryWriter. +func NewInMemoryWriter() *InMemoryWriter { + return &InMemoryWriter{} +} + +// Write implements AuditWriter. +func (w *InMemoryWriter) Write(_ context.Context, ev *AuditEvent) error { + w.mu.Lock() + defer w.mu.Unlock() + w.events = append(w.events, ev) + return nil +} + +// Events returns a copy of all recorded events. +func (w *InMemoryWriter) Events() []*AuditEvent { + w.mu.Lock() + defer w.mu.Unlock() + cp := make([]*AuditEvent, len(w.events)) + copy(cp, w.events) + return cp +} + +// Count returns the number of recorded events. +func (w *InMemoryWriter) Count() int { + w.mu.Lock() + defer w.mu.Unlock() + return len(w.events) +} + +// ── JSONL file writer (for production) ─────────────────────────────────── + +// JSONLWriter appends audit events as JSON lines to a file. +// It is safe for concurrent use. +type JSONLWriter struct { + mu sync.Mutex + file *os.File +} + +// NewJSONLWriter opens or creates a JSONL file for appending. +func NewJSONLWriter(path string) (*JSONLWriter, error) { + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o640) + if err != nil { + return nil, fmt.Errorf("auditlogger: open %s: %w", path, err) + } + return &JSONLWriter{file: f}, nil +} + +// Write implements AuditWriter. Each event is a single JSON line. +func (w *JSONLWriter) Write(_ context.Context, ev *AuditEvent) error { + b, err := json.Marshal(ev) + if err != nil { + return err + } + b = append(b, '\n') + w.mu.Lock() + defer w.mu.Unlock() + _, err = w.file.Write(b) + return err +} + +// Close flushes and closes the underlying file. +func (w *JSONLWriter) Close() error { + w.mu.Lock() + defer w.mu.Unlock() + return w.file.Close() +} + +// Compile-time check. +var _ io.Closer = (*JSONLWriter)(nil) diff --git a/a2asrv/auditlogger/auditlogger_test.go b/a2asrv/auditlogger/auditlogger_test.go new file mode 100644 index 00000000..2149f5c5 --- /dev/null +++ b/a2asrv/auditlogger/auditlogger_test.go @@ -0,0 +1,183 @@ +// Copyright 2026 The A2A Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package auditlogger + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/a2aproject/a2a-go/v2/a2asrv" +) + +func TestAuditLogger_Success(t *testing.T) { + w := NewInMemoryWriter() + al := NewAuditLogger(w) + + ctx := context.Background() + ctx, callCtx := a2asrv.NewCallContext(ctx, nil) + callCtx.User = a2asrv.NewAuthenticatedUser("agent-strategy", nil) + + ctx, _, err := al.Before(ctx, callCtx, &a2asrv.Request{}) + if err != nil { + t.Fatal(err) + } + if err := al.After(ctx, callCtx, &a2asrv.Response{}); err != nil { + t.Fatal(err) + } + + if w.Count() != 1 { + t.Fatalf("expected 1 event, got %d", w.Count()) + } + ev := w.Events()[0] + if ev.AgentID != "agent-strategy" { + t.Errorf("expected agent-strategy, got %s", ev.AgentID) + } + if ev.Result != "success" { + t.Errorf("expected success, got %s", ev.Result) + } + if ev.Duration <= 0 { + t.Errorf("expected positive duration, got %v", ev.Duration) + } +} + +func TestAuditLogger_Error(t *testing.T) { + w := NewInMemoryWriter() + al := NewAuditLogger(w) + + ctx := context.Background() + ctx, callCtx := a2asrv.NewCallContext(ctx, nil) + + ctx, _, _ = al.Before(ctx, callCtx, &a2asrv.Request{}) + _ = al.After(ctx, callCtx, &a2asrv.Response{Err: errors.New("something went wrong")}) + + ev := w.Events()[0] + if ev.Result != "error" { + t.Errorf("expected error, got %s", ev.Result) + } + if ev.ErrorMessage != "something went wrong" { + t.Errorf("expected error message, got %s", ev.ErrorMessage) + } +} + +func TestAuditLogger_Cancelled(t *testing.T) { + w := NewInMemoryWriter() + al := NewAuditLogger(w) + + ctx := context.Background() + ctx, callCtx := a2asrv.NewCallContext(ctx, nil) + + ctx, _, _ = al.Before(ctx, callCtx, &a2asrv.Request{}) + _ = al.After(ctx, callCtx, &a2asrv.Response{Err: context.Canceled}) + + ev := w.Events()[0] + if ev.Result != "cancelled" { + t.Errorf("expected cancelled, got %s", ev.Result) + } +} + +func TestAuditLogger_MultipleEvents(t *testing.T) { + w := NewInMemoryWriter() + al := NewAuditLogger(w) + + for i := 0; i < 5; i++ { + ctx := context.Background() + ctx, callCtx := a2asrv.NewCallContext(ctx, nil) + callCtx.User = a2asrv.NewAuthenticatedUser("agent-ops", nil) + ctx, _, _ = al.Before(ctx, callCtx, &a2asrv.Request{}) + _ = al.After(ctx, callCtx, &a2asrv.Response{}) + } + + if w.Count() != 5 { + t.Fatalf("expected 5 events, got %d", w.Count()) + } +} + +func TestAuditLogger_NoUser(t *testing.T) { + w := NewInMemoryWriter() + al := NewAuditLogger(w) + + ctx := context.Background() + ctx, callCtx := a2asrv.NewCallContext(ctx, nil) + ctx, _, _ = al.Before(ctx, callCtx, &a2asrv.Request{}) + _ = al.After(ctx, callCtx, &a2asrv.Response{}) + + ev := w.Events()[0] + if ev.AgentID != "" { + t.Errorf("expected empty agent_id, got %s", ev.AgentID) + } +} + +func TestJSONLWriter(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "audit.jsonl") + + w, err := NewJSONLWriter(path) + if err != nil { + t.Fatal(err) + } + + ev := &AuditEvent{ + Timestamp: time.Unix(1000, 0), + AgentID: "agent-test", + Method: "SendMessage", + TaskID: "task-123", + Duration: time.Second, + Result: "success", + } + + if err := w.Write(context.Background(), ev); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if len(data) == 0 { + t.Fatal("expected non-empty file") + } +} + +func TestInMemoryWriter_Concurrent(t *testing.T) { + w := NewInMemoryWriter() + al := NewAuditLogger(w) + + done := make(chan bool, 10) + for i := 0; i < 10; i++ { + go func() { + for j := 0; j < 20; j++ { + ctx := context.Background() + ctx, callCtx := a2asrv.NewCallContext(ctx, nil) + ctx, _, _ = al.Before(ctx, callCtx, &a2asrv.Request{}) + _ = al.After(ctx, callCtx, &a2asrv.Response{}) + } + done <- true + }() + } + for i := 0; i < 10; i++ { + <-done + } + + if w.Count() != 200 { + t.Errorf("expected 200 events, got %d", w.Count()) + } +} diff --git a/a2asrv/costlimiter/costlimiter.go b/a2asrv/costlimiter/costlimiter.go new file mode 100644 index 00000000..fc3501ec --- /dev/null +++ b/a2asrv/costlimiter/costlimiter.go @@ -0,0 +1,229 @@ +// Copyright 2026 The A2A Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package costlimiter provides cost-based budget enforcement for A2A server request handling. +// +// While [github.com/a2aproject/a2a-go/v2/a2asrv/limiter.ConcurrencyConfig] limits +// the number of in-flight executions, CostLimiter limits the total cost consumed +// over time. This is useful for enforcing LLM token budgets, compute-time quotas, +// or monetary credit limits per agent or tenant. +// +// CostLimiter is implemented as a [github.com/a2aproject/a2a-go/v2/a2asrv.CallInterceptor] +// and is fully pluggable — the cost estimation function and budget store are both +// provided by the caller. +package costlimiter + +import ( + "context" + "errors" + "fmt" + "sync" + + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/a2aproject/a2a-go/v2/a2asrv/limiter" +) + +// ErrBudgetExceeded indicates that the requested cost exceeds the available budget. +var ErrBudgetExceeded = errors.New("cost budget exceeded") + +// CostFunc computes the expected cost of a request. Implementations may inspect +// the call context, request payload, or any other available information to +// estimate the cost. A return value of 0 means the request has no cost and +// should always be allowed through. +type CostFunc func(ctx context.Context, callCtx *a2asrv.CallContext, req *a2asrv.Request) int64 + +// CostStore tracks cost budgets for named scopes. Implementations are +// responsible for atomicity of Reserve and Release operations. +type CostStore interface { + // Available returns the current available budget for the given scope. + // A negative value conventionally means "unlimited." + Available(ctx context.Context, scope string) (int64, error) + + // Reserve atomically deducts cost from the budget for scope. + // Returns true if the reservation succeeded, false if budget was + // insufficient. Implementations must be safe for concurrent use. + Reserve(ctx context.Context, scope string, cost int64) (bool, error) + + // Release returns previously reserved cost back to the budget. + Release(ctx context.Context, scope string, cost int64) error +} + +// ScopeFunc derives a scope identifier from the call context. If nil, +// the scope attached to the context via [limiter.AttachScope] is used. +type ScopeFunc func(callCtx *a2asrv.CallContext) string + +// A CostLimiter is a server-side [a2asrv.CallInterceptor] that enforces +// cost-based budgets on agent executions. +// +// The zero value is not usable; use [NewCostLimiter]. +type CostLimiter struct { + a2asrv.PassthroughCallInterceptor + + costFn CostFunc + store CostStore + scopeFn ScopeFunc +} + +// NewCostLimiter creates a CostLimiter that uses store to track budgets +// and costFn to estimate the cost of each request. +func NewCostLimiter(store CostStore, costFn CostFunc, opts ...Option) *CostLimiter { + cl := &CostLimiter{ + store: store, + costFn: costFn, + } + for _, opt := range opts { + opt(cl) + } + // If no custom scope function was provided, the Before method will + // fall back to the scope attached via limiter.AttachScope, as + // documented in the ScopeFunc docstring. + return cl +} + +// Option configures a CostLimiter. +type Option func(*CostLimiter) + +// WithScopeFunc sets a custom scope derivation function. +func WithScopeFunc(fn ScopeFunc) Option { + return func(cl *CostLimiter) { + cl.scopeFn = fn + } +} + +// Before implements [a2asrv.CallInterceptor]. It estimates the request cost +// and attempts to reserve budget. If the budget is insufficient, it returns +// a rate-limited error before the agent executor is invoked. +func (cl *CostLimiter) Before(ctx context.Context, callCtx *a2asrv.CallContext, req *a2asrv.Request) (context.Context, any, error) { + scope := cl.resolveScope(ctx, callCtx) + cost := cl.costFn(ctx, callCtx, req) + if cost <= 0 { + return ctx, nil, nil + } + + ok, err := cl.store.Reserve(ctx, scope, cost) + if err != nil { + return ctx, nil, fmt.Errorf("costlimiter: reserving budget: %w", err) + } + if !ok { + return ctx, nil, fmt.Errorf("%w: scope %q: budget exhausted", ErrBudgetExceeded, scope) + } + + return context.WithValue(ctx, reservationKeyType{}, reservation{scope: scope, cost: cost}), nil, nil +} + +// resolveScope returns the scope for this call. If a custom ScopeFunc was +// provided, it is used. Otherwise, falls back to the scope attached via +// [limiter.AttachScope]. +func (cl *CostLimiter) resolveScope(ctx context.Context, callCtx *a2asrv.CallContext) string { + if cl.scopeFn != nil { + return cl.scopeFn(callCtx) + } + if s, ok := limiter.ScopeFrom(ctx); ok && s != "" { + return s + } + return "default" +} + +// After implements [a2asrv.CallInterceptor]. If the execution was cancelled +// before doing meaningful work, the reserved cost is released. The release +// uses context.WithoutCancel so that cancelled contexts do not prevent +// budget restoration. +func (cl *CostLimiter) After(ctx context.Context, _ *a2asrv.CallContext, resp *a2asrv.Response) error { + res, ok := reservationFrom(ctx) + if !ok { + return nil + } + + if resp != nil && resp.Err != nil && errors.Is(resp.Err, context.Canceled) { + _ = cl.store.Release(context.WithoutCancel(ctx), res.scope, res.cost) + } + return nil +} + +type reservationKeyType struct{} + +type reservation struct { + scope string + cost int64 +} + +func reservationFrom(ctx context.Context) (reservation, bool) { + v, ok := ctx.Value(reservationKeyType{}).(reservation) + return v, ok +} + +// InMemoryCostStore is an in-memory implementation of [CostStore] suitable +// for single-process deployments. It is safe for concurrent use. +type InMemoryCostStore struct { + mu sync.Mutex + budget map[string]int64 +} + +// NewInMemoryCostStore creates an InMemoryCostStore with the given initial +// budgets. A negative budget value means "unlimited" for that scope. +// Scopes not present in the map also default to unlimited. +func NewInMemoryCostStore(budgets map[string]int64) *InMemoryCostStore { + cp := make(map[string]int64, len(budgets)) + for k, v := range budgets { + cp[k] = v + } + return &InMemoryCostStore{budget: cp} +} + +// Available implements CostStore. +func (s *InMemoryCostStore) Available(_ context.Context, scope string) (int64, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.budget[scope] + if !ok { + return -1, nil + } + return v, nil +} + +// Reserve implements CostStore. +// Scopes not present in the initial budgets map are treated as unlimited +// and are not tracked — they always succeed. +func (s *InMemoryCostStore) Reserve(_ context.Context, scope string, cost int64) (bool, error) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.budget[scope] + if !ok { + // Unknown scope — unlimited, no tracking. + return true, nil + } + if v < 0 { + return true, nil + } + if v < cost { + return false, nil + } + s.budget[scope] = v - cost + return true, nil +} + +// Release implements CostStore. +func (s *InMemoryCostStore) Release(_ context.Context, scope string, cost int64) error { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.budget[scope] + if !ok { + return nil + } + if v < 0 { + return nil + } + s.budget[scope] = v + cost + return nil +} diff --git a/a2asrv/costlimiter/costlimiter_test.go b/a2asrv/costlimiter/costlimiter_test.go new file mode 100644 index 00000000..80dd50dc --- /dev/null +++ b/a2asrv/costlimiter/costlimiter_test.go @@ -0,0 +1,237 @@ +// Copyright 2026 The A2A Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package costlimiter + +import ( + "context" + "errors" + "testing" + + "github.com/a2aproject/a2a-go/v2/a2asrv" + "github.com/a2aproject/a2a-go/v2/a2asrv/limiter" +) + +func TestCostLimiter_Before_ZeroCostPassthrough(t *testing.T) { + store := NewInMemoryCostStore(map[string]int64{"default": 100}) + cl := NewCostLimiter(store, func(ctx context.Context, callCtx *a2asrv.CallContext, req *a2asrv.Request) int64 { + return 0 + }) + + ctx := context.Background() + ctx, callCtx := a2asrv.NewCallContext(ctx, nil) + _, _, err := cl.Before(ctx, callCtx, &a2asrv.Request{}) + if err != nil { + t.Fatalf("zero-cost request should pass through, got: %v", err) + } +} + +func TestCostLimiter_Before_WithinBudget(t *testing.T) { + store := NewInMemoryCostStore(map[string]int64{"default": 100}) + cl := NewCostLimiter(store, func(ctx context.Context, callCtx *a2asrv.CallContext, req *a2asrv.Request) int64 { + return 50 + }) + + ctx := context.Background() + ctx, callCtx := a2asrv.NewCallContext(ctx, nil) + ctx, _, err := cl.Before(ctx, callCtx, &a2asrv.Request{}) + if err != nil { + t.Fatalf("request within budget should pass, got: %v", err) + } + + // Second call should also pass (50+50=100=budget) + ctx, _, err = cl.Before(ctx, callCtx, &a2asrv.Request{}) + if err != nil { + t.Fatalf("second request within budget should pass, got: %v", err) + } + + // Third call should exceed budget (150>100) + _, _, err = cl.Before(ctx, callCtx, &a2asrv.Request{}) + if !errors.Is(err, ErrBudgetExceeded) { + t.Fatalf("expected ErrBudgetExceeded, got: %v", err) + } +} + +func TestCostLimiter_Before_ExceedsBudget(t *testing.T) { + store := NewInMemoryCostStore(map[string]int64{"default": 10}) + cl := NewCostLimiter(store, func(ctx context.Context, callCtx *a2asrv.CallContext, req *a2asrv.Request) int64 { + return 100 + }) + + ctx := context.Background() + ctx, callCtx := a2asrv.NewCallContext(ctx, nil) + _, _, err := cl.Before(ctx, callCtx, &a2asrv.Request{}) + if !errors.Is(err, ErrBudgetExceeded) { + t.Fatalf("expected ErrBudgetExceeded, got: %v", err) + } +} + +func TestCostLimiter_Before_UnlimitedBudget(t *testing.T) { + store := NewInMemoryCostStore(map[string]int64{"default": -1}) // unlimited + cl := NewCostLimiter(store, func(ctx context.Context, callCtx *a2asrv.CallContext, req *a2asrv.Request) int64 { + return 9999 + }) + + ctx := context.Background() + ctx, callCtx := a2asrv.NewCallContext(ctx, nil) + _, _, err := cl.Before(ctx, callCtx, &a2asrv.Request{}) + if err != nil { + t.Fatalf("unlimited budget should allow any cost, got: %v", err) + } +} + +func TestCostLimiter_After_ReleaseOnCancel(t *testing.T) { + store := NewInMemoryCostStore(map[string]int64{"default": 100}) + cl := NewCostLimiter(store, func(ctx context.Context, callCtx *a2asrv.CallContext, req *a2asrv.Request) int64 { + return 50 + }) + + ctx := context.Background() + ctx, callCtx := a2asrv.NewCallContext(ctx, nil) + ctx, _, err := cl.Before(ctx, callCtx, &a2asrv.Request{}) + if err != nil { + t.Fatal(err) + } + + // Simulate cancellation + _ = cl.After(ctx, callCtx, &a2asrv.Response{Err: context.Canceled}) + + // Budget should be restored + avail, _ := store.Available(ctx, "default") + if avail != 100 { + t.Fatalf("budget should be restored after cancel, got %d", avail) + } +} + +func TestCostLimiter_After_NoReleaseOnSuccess(t *testing.T) { + store := NewInMemoryCostStore(map[string]int64{"default": 100}) + cl := NewCostLimiter(store, func(ctx context.Context, callCtx *a2asrv.CallContext, req *a2asrv.Request) int64 { + return 50 + }) + + ctx := context.Background() + ctx, callCtx := a2asrv.NewCallContext(ctx, nil) + ctx, _, err := cl.Before(ctx, callCtx, &a2asrv.Request{}) + if err != nil { + t.Fatal(err) + } + + // Successful completion — cost should NOT be released + _ = cl.After(ctx, callCtx, &a2asrv.Response{}) + + avail, _ := store.Available(ctx, "default") + if avail != 50 { + t.Fatalf("budget should remain deducted after success, got %d", avail) + } +} + +func TestCostLimiter_ScopeFunc(t *testing.T) { + store := NewInMemoryCostStore(map[string]int64{"agent-a": 10, "agent-b": 100}) + cl := NewCostLimiter(store, + func(ctx context.Context, callCtx *a2asrv.CallContext, req *a2asrv.Request) int64 { + return 10 + }, + WithScopeFunc(func(callCtx *a2asrv.CallContext) string { + return callCtx.User.Name + }), + ) + + // "Agent-b" has enough budget + ctx := context.Background() + ctx, callCtx := a2asrv.NewCallContext(ctx, nil) + callCtx.User = a2asrv.NewAuthenticatedUser("agent-b", nil) + ctx2, callCtx2 := a2asrv.NewCallContext(ctx, nil) + callCtx2.User = a2asrv.NewAuthenticatedUser("agent-b", nil) + _, _, err := cl.Before(ctx2, callCtx2, &a2asrv.Request{}) + if err != nil { + t.Fatalf("agent-b should have budget, got: %v", err) + } + + // "Agent-a" has exactly 10, so one more call should fail + ctx3, callCtx3 := a2asrv.NewCallContext(ctx, nil) + callCtx3.User = a2asrv.NewAuthenticatedUser("agent-a", nil) + _, _, err = cl.Before(ctx3, callCtx3, &a2asrv.Request{}) + if err != nil { + t.Fatal("first call for agent-a should pass", err) + } + ctx4, callCtx4 := a2asrv.NewCallContext(ctx, nil) + callCtx4.User = a2asrv.NewAuthenticatedUser("agent-a", nil) + _, _, err = cl.Before(ctx4, callCtx4, &a2asrv.Request{}) + if !errors.Is(err, ErrBudgetExceeded) { + t.Fatalf("agent-a should be out of budget, got: %v", err) + } +} + +func TestCostLimiter_FallbackToLimiterScope(t *testing.T) { + store := NewInMemoryCostStore(map[string]int64{"tenant-x": 50}) + // No custom ScopeFunc — should fall back to limiter.AttachScope. + cl := NewCostLimiter(store, func(ctx context.Context, callCtx *a2asrv.CallContext, req *a2asrv.Request) int64 { + return 30 + }) + + ctx := context.Background() + ctx = limiter.AttachScope(ctx, "tenant-x") + ctx, callCtx := a2asrv.NewCallContext(ctx, nil) + _, _, err := cl.Before(ctx, callCtx, &a2asrv.Request{}) + if err != nil { + t.Fatal(err) + } + + // Second call should fail (30+30=60 > 50) + _, _, err = cl.Before(ctx, callCtx, &a2asrv.Request{}) + if !errors.Is(err, ErrBudgetExceeded) { + t.Fatalf("expected ErrBudgetExceeded, got: %v", err) + } +} + +func TestInMemoryCostStore_UnlimitedScopeNotStored(t *testing.T) { + store := NewInMemoryCostStore(map[string]int64{"limited": 100}) + ok, err := store.Reserve(context.Background(), "unlimited-scope", 50) + if err != nil { + t.Fatal(err) + } + if !ok { + t.Fatal("unlimited scope should succeed") + } + avail, _ := store.Available(context.Background(), "unlimited-scope") + if avail != -1 { + t.Fatalf("unlimited scope should not be stored, got available=%d", avail) + } +} + +func TestInMemoryCostStore_Concurrent(t *testing.T) { + store := NewInMemoryCostStore(map[string]int64{"shared": 1000}) + + done := make(chan bool, 10) + for i := 0; i < 10; i++ { + go func() { + for j := 0; j < 50; j++ { + _, _ = store.Reserve(context.Background(), "shared", 1) + } + done <- true + }() + } + + for i := 0; i < 10; i++ { + <-done + } + + avail, _ := store.Available(context.Background(), "shared") + if avail < 0 { + t.Fatalf("budget underflow: %d", avail) + } + if avail != 500 { + t.Logf("remaining budget: %d (expected ~500, concurrency may vary)", avail) + } +}