Skip to content
Open
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
18 changes: 18 additions & 0 deletions agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"google.golang.org/genai"

"google.golang.org/adk/v2/artifact"
"google.golang.org/adk/v2/internal/adkcontext"
agentinternal "google.golang.org/adk/v2/internal/agent"
"google.golang.org/adk/v2/internal/plugininternal/plugincontext"
"google.golang.org/adk/v2/internal/telemetry"
Expand Down Expand Up @@ -412,6 +413,23 @@ func (c *invocationContext) Session() session.Session {
return c.session
}

// Value implements context.Context, answering the ADK identity key like every
// other invocation context so a promoted copy and this one cannot disagree. It
// owns its session, so no session means no identity — never the enclosing
// invocation's, whose user made no such call.
func (c *invocationContext) Value(key any) any {
if key == adkcontext.IdentityKey {
if id, ok := identityOf(func() session.Session { return c.session }); ok {
return id
}
return nil
}
if c.Context == nil {
return nil
}
return c.Context.Value(key)
}

func (c *invocationContext) InvocationID() string {
return c.invocationID
}
Expand Down
128 changes: 128 additions & 0 deletions agent/common_context.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,67 @@ import (
"google.golang.org/genai"

"google.golang.org/adk/v2/artifact"
"google.golang.org/adk/v2/internal/adkcontext"
"google.golang.org/adk/v2/memory"
"google.golang.org/adk/v2/platform"
"google.golang.org/adk/v2/session"
"google.golang.org/adk/v2/tool/toolconfirmation"
)

// Identity is an ADK invocation's identity: the acting user, app name, and
// session a call belongs to. It is recovered from a plain context.Context via
// [IdentityFromContext], which reads it off the live session each time, so a
// session mutated mid-invocation is reflected in the next lookup.
type Identity struct {
// UserID is the acting end user, as the embedding server put it on the
// session. ADK does not authenticate it: anything acting on behalf of this
// user — minting a per-user credential, for instance — is trusting the server
// to have bound session.UserID to an authenticated principal. ADK's own REST
// server takes it from the request body.
UserID string
// AppName is the app the invocation belongs to.
AppName string
// SessionID identifies the conversation the invocation belongs to.
SessionID string
}

// identityOf reads an invocation identity from the session getSession returns.
// It is the one place package agent turns a session into an [Identity], so every
// context type here answers the identity key the same way.
//
// getSession is called inside the recover, not before it: Session() is itself a
// method on caller-supplied code and can panic on its own.
func identityOf(getSession func() session.Session) (Identity, bool) {
return adkcontext.Recovered(func() Identity {
// One Session value, then one call per field: re-reading Session() per
// field risks a torn identity, and some context wrappers log on every read.
s := getSession()
return Identity{UserID: s.UserID(), AppName: s.AppName(), SessionID: s.ID()}
})
}

// IdentityFromContext returns the ADK invocation [Identity] carried by ctx, if
// present.
//
// ADK contexts embed context.Context and register their identity under a private
// key, so code that only holds a context.Context — for example an
// http.RoundTripper running deep beneath a tool call, past intermediaries that
// wrap the context — can recover the acting identity without threading a typed
// context through every layer.
//
// It returns (zero, false) both for a context that does not descend from an ADK
// context and for an invocation with no readable session; the two are not
// distinguishable here. The invocation types in this module never report an
// enclosing invocation's user in place of their own — one implemented elsewhere
// cannot make that promise, since the key is unnameable outside the module and
// its embedded parent answers instead. ok does not imply a populated Identity
// either: an invocation whose session carries no user yields an empty UserID, so
// a caller that needs one must check.
func IdentityFromContext(ctx context.Context) (Identity, bool) {
id, ok := ctx.Value(adkcontext.IdentityKey).(Identity)
return id, ok
}

// In general CommonContext should not be wrapped with contexts not providing agent.Context.
// It allows to copy&modify context instead of building chains.

Expand Down Expand Up @@ -344,6 +399,79 @@ func (c *commonContext) UserID() string {
return c.invocationContext.Session().UserID()
}

// Value implements context.Context. For the ADK identity key it returns the
// [Identity] of the invocation this context speaks for (so [IdentityFromContext]
// can recover it from a derived context); every other key delegates to the
// embedded context, preserving existing behavior.
//
// Only the identity key touches the invocation, so no other key is affected by
// its state, and a session that panics costs the identity, not the process.
func (c *commonContext) Value(key any) any {
if key == adkcontext.IdentityKey {
return c.identity()
}
if c.Context == nil {
return nil
}
return c.Context.Value(key)
}

// identity answers the ADK identity key, as an any so it can report "none".
//
// A commonContext owns no session, so it speaks for the invocation it wraps, and
// reads that invocation's own session first. Asking the invocation's Value first
// looks equivalent and is not: an InvocationContext written outside this module
// embeds the context it was derived from, to inherit cancellation, and cannot
// override a key it cannot name — so its Value answers with the *enclosing*
// invocation's identity even when it has a session of its own naming a different
// user. Reading the session first is what makes an invocation report itself.
//
// Only an invocation with no readable session of its own delegates, by asking the
// invocation it wraps. A tool or callback context is exactly that shape by design
// and hands the key to the context underneath. An invocation that owns a session
// field and has none — a nested invocation built without one — gets nil back from
// that ask and reports no identity, rather than inheriting a user who made no
// such call.
//
// A commonContext speaking for no invocation at all is the one case that consults
// its own parent, since there is nothing else it could answer for.
func (c *commonContext) identity() any {
if c.invocationContext == nil {
if c.Context == nil {
return nil
}
return c.Context.Value(adkcontext.IdentityKey)
}
// Method value and call both inside the recover: Session() is caller-supplied
// code and invocationContext can be a typed-nil pointer.
s, read := adkcontext.Recovered(func() session.Session { return c.invocationContext.Session() })
if !read {
// The invocation cannot say who it is. It must not inherit an answer from
// what it embeds: a broken invocation is not an absent one, and the context
// it was derived from belongs to a different call.
return nil
}
if s != nil {
if id, ok := identityOf(func() session.Session { return s }); ok {
return id
}
// Present but unreadable is broken too, and delegating would inherit.
return nil
}
// No session of its own, which a tool or callback context is by design, so ask
// the invocation. Type-asserted, not merely checked against nil: one with a
// permissive Value that answers every key would otherwise hand back something
// that is not an Identity and be taken for one.
if v, ok := adkcontext.Recovered(func() any {
return c.invocationContext.Value(adkcontext.IdentityKey)
}); ok {
if id, isIdentity := v.(Identity); isIdentity {
return id
}
}
return nil
}

var (
_ Context = (*commonContext)(nil)
_ InvocationContext = (*commonContext)(nil)
Expand Down
214 changes: 214 additions & 0 deletions agent/identity_matrix_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
// Copyright 2026 Google LLC
//
// 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 agent

import (
"context"
"testing"
"time"

"google.golang.org/adk/v2/session"
)

// The identity key is answered by a decision procedure, so it is pinned by a
// table rather than by cases. Rows are the shapes a session or an invocation can
// legally take, columns the ways a context is derived from one. Reviewing this
// procedure a diff at a time found one failing shape per round over five rounds,
// each fix moving the failure to a neighbouring shape.
//
// The policy the table encodes:
// - An invocation reports the user of its OWN session, never one it inherited.
// - An invocation with no session of its own delegates, which is what a tool or
// callback context needs — theirs is nil by design.
// - An invocation that cannot answer at all reports nothing. Broken is not
// absent, and delegating would hand back a different call's user.
// - No shape panics out of Value, and no shape disturbs an unrelated key.

type matrixSession struct {
session.Session
id, app, user string
}

func (s *matrixSession) ID() string { return s.id }
func (s *matrixSession) AppName() string { return s.app }
func (s *matrixSession) UserID() string { return s.user }
func (s *matrixSession) State() session.State { return nil }
func (s *matrixSession) Events() session.Events { return nil }
func (s *matrixSession) LastUpdateTime() time.Time { return time.Time{} }

func matrixOwner(user string) session.Session {
return &matrixSession{id: "sid-" + user, app: "app", user: user}
}

// structSession is a value type, which a six-accessor interface invites and which
// a reflect-based nil check cannot inspect.
type structSession struct{ session.Session }

func (structSession) ID() string { return "sid" }
func (structSession) AppName() string { return "app" }
func (structSession) UserID() string { return "owner" }

// safeNilSession answers without touching its receiver, so a typed-nil one works.
type safeNilPtrSession struct{ session.Session }

func (*safeNilPtrSession) ID() string { return "sid" }
func (*safeNilPtrSession) AppName() string { return "app" }
func (*safeNilPtrSession) UserID() string { return "owner" }

// nilWrappingSession promotes its accessors from a nil embedded session, the
// shape llmagent.newWrappedSession produces for a nil original.
type nilWrappingSession struct{ session.Session }

// panickingAccessorSession is broken in its own code, not in its nil-ness.
type panickingAccessorSession struct{ session.Session }

func (panickingAccessorSession) ID() string { return "sid" }
func (panickingAccessorSession) AppName() string { return "app" }
func (panickingAccessorSession) UserID() string { panic("accessor is not available") }

// panickingSessionInvocation declines to hand over a session at all, as the
// exported StrictContextMock does.
type panickingSessionInvocation struct{ InvocationContext }

func (panickingSessionInvocation) Session() session.Session { panic("Session is not available") }

// permissiveInvocationValue answers every key, as a decorator or double might.
type permissiveInvocationValue struct{ InvocationContext }

func (permissiveInvocationValue) Value(any) any { return "something that is not an Identity" }

// decoratedInvocationValue is the shape written outside this module: embed the
// invocation you were derived from to inherit cancellation, carry your own
// session. It cannot override the identity key, because it cannot name it.
type decoratedInvocationValue struct {
InvocationContext
own session.Session
}

func (d decoratedInvocationValue) Session() session.Session { return d.own }

func TestIdentityDecisionMatrix(t *testing.T) {
// Every row is nested under this one, so any cell that reports "enclosing" is
// serving a user who made no such call.
enclosing := &invocationContext{Context: t.Context(), session: matrixOwner("enclosing")}

rows := []struct {
name string
ic func() InvocationContext
want string // "" means no identity
// outsideModule marks an invocation that cannot override the identity key,
// so asking it directly answers with whatever it embeds. That is the
// documented limit of the mechanism; the derivations are what fix it.
outsideModule bool
}{
{name: "pointer session", want: "u", ic: func() InvocationContext {
return &invocationContext{Context: enclosing, session: matrixOwner("u")}
}},
{name: "struct-value session", want: "owner", ic: func() InvocationContext {
return &invocationContext{Context: enclosing, session: structSession{}}
}},
{name: "typed-nil with safe accessors", want: "owner", ic: func() InvocationContext {
return &invocationContext{Context: enclosing, session: (*safeNilPtrSession)(nil)}
}},
{name: "no session", ic: func() InvocationContext {
return &invocationContext{Context: enclosing}
}},
{name: "typed-nil session", ic: func() InvocationContext {
return &invocationContext{Context: enclosing, session: (*matrixSession)(nil)}
}},
{name: "session wrapping a nil session", ic: func() InvocationContext {
return &invocationContext{Context: enclosing, session: &nilWrappingSession{}}
}},
{name: "session accessor panics", ic: func() InvocationContext {
return &invocationContext{Context: enclosing, session: panickingAccessorSession{}}
}},
{name: "Session() panics", outsideModule: true, ic: func() InvocationContext {
return panickingSessionInvocation{InvocationContext: enclosing}
}},
{name: "permissive Value, own session", want: "u", outsideModule: true, ic: func() InvocationContext {
return permissiveInvocationValue{InvocationContext: &invocationContext{Context: enclosing, session: matrixOwner("u")}}
}},
{name: "decorated outside the module", want: "u", outsideModule: true, ic: func() InvocationContext {
return decoratedInvocationValue{InvocationContext: enclosing, own: matrixOwner("u")}
}},
// The two axes have to be crossed, not just walked. An invocation that owns
// a session fails closed on its own, so an unreadable session only reaches
// the delegation through a decorator — where inheriting is a live user's
// credential minted for someone else's call.
{name: "decorated, typed-nil session", outsideModule: true, ic: func() InvocationContext {
return decoratedInvocationValue{InvocationContext: enclosing, own: (*matrixSession)(nil)}
}},
{name: "decorated, session accessor panics", outsideModule: true, ic: func() InvocationContext {
return decoratedInvocationValue{InvocationContext: enclosing, own: panickingAccessorSession{}}
}},
}

// Two columns deliberately put a value on the chain, so the probe for "an
// unrelated key is undisturbed" must use a key nothing injects.
type unrelatedKey struct{}
type probeKey struct{}
cols := []struct {
name string
of func(InvocationContext) context.Context
}{
{"the invocation itself", func(ic InvocationContext) context.Context { return ic }},
{"Promote", func(ic InvocationContext) context.Context { return Promote(ic) }},
{"NewContext", func(ic InvocationContext) context.Context { return NewContext(ic) }},
{"NewToolContext", func(ic InvocationContext) context.Context { return NewToolContext(ic, "fc", nil, nil) }},
{"NewCallbackContext", func(ic InvocationContext) context.Context { return NewCallbackContext(ic, nil) }},
{"NewCallbackContextWithArtifactTracking", func(ic InvocationContext) context.Context {
return NewCallbackContextWithArtifactTracking(ic, nil)
}},
{"reparented onto a carrier of the enclosing invocation", func(ic InvocationContext) context.Context {
return Promote(ic).WithContext(context.WithValue(context.Context(enclosing), unrelatedKey{}, "x"))
}},
{"tool context of a tool context", func(ic InvocationContext) context.Context {
return NewToolContext(NewToolContext(ic, "a", nil, nil), "b", nil, nil)
}},
{"behind a non-ADK wrapper", func(ic InvocationContext) context.Context {
return context.WithValue(Promote(ic), unrelatedKey{}, "x")
}},
}

for _, r := range rows {
for _, c := range cols {
if c.name == "the invocation itself" && r.outsideModule {
continue
}
t.Run(r.name+" / "+c.name, func(t *testing.T) {
defer func() {
if p := recover(); p != nil {
t.Fatalf("Value panicked: %v", p)
}
}()
ctx := c.of(r.ic())
var got string
if id, ok := IdentityFromContext(ctx); ok {
got = id.UserID
}
if got != r.want {
t.Errorf("IdentityFromContext() user = %q, want %q", got, r.want)
}
// An invocation that hijacks every key is answering for itself.
if _, hijacks := r.ic().(permissiveInvocationValue); hijacks {
return
}
if v := ctx.Value(probeKey{}); v != nil {
t.Errorf("Value(probeKey{}) = %v, want nil: only the identity key reads the session", v)
}
})
}
}
}
Loading
Loading