Skip to content

Tool execution context: invocation data for tools and deterministic registry ordering #102

Description

@weeco

Problem Statement

Two related issues in the tool system limit its usefulness for agent builders:

1. Tools don't receive invocation context

Tool.Execute(ctx, args) receives only a context.Context and the JSON arguments. The ExecutionContext struct exists in tool/registry.go but is never passed to tools. Tools cannot access:

  • Session data: Which session is this tool executing in? What's the conversation history?
  • User identity: Who is the user? What are their permissions?
  • Invocation metadata: Which turn is this? Which agent invoked me? What's the invocation ID?
  • Other tool results: What did previous tools return in this turn?
  • Memory/artifacts: Read or write shared state across tool invocations.

This means every tool that needs context must either:

  • Receive it through the JSON args (requires the LLM to pass it, unreliable)
  • Use context.Value() (untyped, error-prone, no SDK support)
  • Be implemented as a closure that captures state (limits reusability)

2. Registry.List() ordering is non-deterministic

Registry.List() at registry.go:151 iterates over a map[string]*registeredTool, which means tool ordering in the LLM prompt varies between calls. This causes:

  • Non-reproducible behavior: The same agent with the same tools may behave differently between runs because tool ordering in the prompt affects LLM tool selection.
  • Test flakiness: Tests that assert on tool definitions or request contents are order-dependent.
  • Debugging difficulty: Comparing two requests is harder when tool order changes randomly.

Proposed Solution

Typed tool execution context

Extend Tool.Execute to receive a typed context that provides invocation data:

Option A: Extend the interface (breaking)

type Tool interface {
    Definition() llm.ToolDefinition
    Execute(ctx context.Context, execCtx *ExecutionContext, args json.RawMessage) (json.RawMessage, error)
}

type ExecutionContext struct {
    InvocationID string
    SessionID    string
    Turn         int
    UserID       string
    Metadata     map[string]any    // Interceptor-set metadata
    Session      *session.State    // Read-only session access
}

Option B: Context key (non-breaking)

// SDK sets this before calling Execute
type executionContextKey struct{}

func ExecutionContextFromContext(ctx context.Context) (*ExecutionContext, bool) {
    ec, ok := ctx.Value(executionContextKey{}).(*ExecutionContext)
    return ec, ok
}

// Tools opt in to reading it:
func (t *MyTool) Execute(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
    if ec, ok := tool.ExecutionContextFromContext(ctx); ok {
        // Access ec.SessionID, ec.UserID, etc.
    }
    // ...
}

Option B is preferred as it maintains backward compatibility — existing tools continue to work unchanged, and new tools can opt in to reading the execution context.

Deterministic tool ordering

Change Registry to maintain insertion order:

type registry struct {
    mu       sync.RWMutex
    tools    map[string]*registeredTool
    order    []string  // insertion order
    config   RegistryConfig
}

List() returns tools in insertion order. Unregister() removes from both the map and the order slice. This is a simple, predictable default.

Alternatively, sort alphabetically in List() — either way, the output must be deterministic.

Use Case Example

Authorization-aware tool:

func (t *DeleteResourceTool) Execute(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
    ec, ok := tool.ExecutionContextFromContext(ctx)
    if !ok {
        return nil, errors.New("execution context required")
    }

    // Check user permissions
    if !hasPermission(ec.UserID, "delete") {
        return json.Marshal(map[string]string{"error": "permission denied"})
    }

    // Use session metadata for context
    env := ec.Metadata["environment"] // Set by an interceptor
    // ...
}

Audit logging tool:

func (t *AuditTool) Execute(ctx context.Context, args json.RawMessage) (json.RawMessage, error) {
    ec, _ := tool.ExecutionContextFromContext(ctx)
    log.Info("tool executed",
        "tool", t.Definition().Name,
        "user", ec.UserID,
        "session", ec.SessionID,
        "invocation", ec.InvocationID,
        "turn", ec.Turn,
    )
    // ...
}

Why This Matters

  • Authorization: Tools that perform sensitive operations need to know who is requesting the action. Without invocation context, tools can't enforce authorization, and the only alternative is passing user identity through LLM arguments (insecure and unreliable).
  • Audit and compliance: Production tools need to log who did what, when, and in which context. Without structured execution context, audit logs lack the correlation IDs needed for traceability.
  • Deterministic behavior: Non-deterministic tool ordering introduces a source of randomness that makes agent behavior harder to reproduce, test, and debug. Tool order should be a controlled variable, not a random one.
  • Tool reusability: Tools that can read invocation context are more reusable across agents. Instead of building context-awareness into each agent's tool wiring, tools can self-serve the context they need.

Metadata

Metadata

Assignees

No one assigned

    Labels

    breaking-changeRequires breaking API changesenhancementNew feature or requesttoolTool system

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions