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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import io.github.agentic.ai.graph.OverAllState;
import io.github.agentic.ai.graph.RunnableConfig;
import io.github.agentic.ai.graph.execution.TrustedExecutionContext;

import java.util.Optional;

Expand Down Expand Up @@ -57,5 +58,8 @@ public Optional<String> checkpointId() {
return this.config.checkPointId();
}

}
public Optional<TrustedExecutionContext> trustedExecutionContext() {
return this.config.trustedExecutionContext();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ public static Builder builder(ToolCallRequest request) {
.toolName(request.toolName)
.arguments(request.arguments)
.toolCallId(request.toolCallId)
.context(request.context);
.context(request.context)
.executionContext(request.executionContext);
}

public String getToolName() {
Expand Down Expand Up @@ -136,4 +137,3 @@ public ToolCallRequest build() {
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -659,15 +659,21 @@ private ToolCallResponse executeToolCallWithInterceptors(AssistantMessage.ToolCa
config.assertExecutionActive();
Map<String, Object> toolContextMap = new HashMap<>(toolContext);
toolContextMap.putAll(req.getContext());
// Framework context keys are authoritative and must never be supplied by
// RunnableConfig metadata, interceptor rewrites or model-controlled input.
toolContextMap.remove(AGENT_STATE_CONTEXT_KEY);
toolContextMap.remove(AGENT_CONFIG_CONTEXT_KEY);
toolContextMap.remove(AGENT_STATE_FOR_UPDATE_CONTEXT_KEY);
toolContextMap.put(AGENT_CONFIG_CONTEXT_KEY, config);

// Handle tools that need state injection:
// - StateAwareToolCallback (including AsyncToolCallback)
// - FunctionToolCallback
// - MethodToolCallback
if (toolCallback instanceof StateAwareToolCallback || toolCallback instanceof FunctionToolCallback<?, ?>
|| toolCallback instanceof MethodToolCallback) {
toolContextMap.putAll(Map.of(AGENT_STATE_CONTEXT_KEY, state, AGENT_CONFIG_CONTEXT_KEY, config,
AGENT_STATE_FOR_UPDATE_CONTEXT_KEY, extraStateFromToolCall));
toolContextMap.putAll(Map.of(AGENT_STATE_CONTEXT_KEY, state, AGENT_STATE_FOR_UPDATE_CONTEXT_KEY,
extraStateFromToolCall));
}

// Route to async or sync execution based on callback type
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import io.github.agentic.ai.graph.OverAllState;
import io.github.agentic.ai.graph.RunnableConfig;
import io.github.agentic.ai.graph.execution.TrustedExecutionContext;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.lang.Nullable;

Expand Down Expand Up @@ -79,6 +80,17 @@ public static Optional<RunnableConfig> getConfig(@Nullable ToolContext toolConte
return getMetadata(toolContext, ToolContextConstants.AGENT_CONFIG_CONTEXT_KEY, RunnableConfig.class);
}

/**
* Get trusted identity facts from the framework-injected {@link RunnableConfig}.
* Arbitrary ToolContext entries and ordinary metadata are never interpreted as trusted
* identity.
* @param toolContext the tool context
* @return the explicitly attached trusted execution context, if present
*/
public static Optional<TrustedExecutionContext> getTrustedExecutionContext(@Nullable ToolContext toolContext) {
return getConfig(toolContext).flatMap(RunnableConfig::trustedExecutionContext);
}

/**
* Get the agent state (OverAllState) from ToolContext.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@
import io.github.agentic.ai.graph.agent.hook.ModelHook;
import io.github.agentic.ai.graph.agent.tools.ToolContextConstants;
import io.github.agentic.ai.graph.checkpoint.savers.MemorySaver;
import io.github.agentic.ai.graph.execution.DelegationScope;
import io.github.agentic.ai.graph.execution.PrincipalReference;
import io.github.agentic.ai.graph.execution.TenantReference;
import io.github.agentic.ai.graph.execution.TrustedExecutionContext;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.AssistantMessage;
Expand All @@ -34,6 +38,7 @@

import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.atomic.AtomicReference;

Expand All @@ -60,6 +65,13 @@ void shouldOverrideAgentMetadataAndPreserveBusinessMetadata() {
.threadId("parent-thread")
.addMetadata("_AGENT_", "parent_agent")
.addMetadata("business_key", "business-value")
.trustedExecutionContext(TrustedExecutionContext.builder()
.tenant(new TenantReference("example-iam", "tenant-a"))
.principal(new PrincipalReference("example-iam", "principal-a"))
.runId("run-parent")
.correlationId("correlation-a")
.delegationScope(DelegationScope.of(Set.of("agents:delegate")))
.build())
.build();

AssistantMessage result = new AgentTool.AgentToolExecutor(subAgent)
Expand All @@ -76,6 +88,10 @@ void shouldOverrideAgentMetadataAndPreserveBusinessMetadata() {
"Business metadata from parent config should be preserved");
assertEquals("parent-thread_child_agent", capturedConfig.threadId().orElse(null),
"Sub-agent thread id should be derived from the parent thread id");
assertEquals("tenant-a", capturedConfig.trustedExecutionContext().orElseThrow().tenant().id(),
"Sub-agent should receive the trusted tenant reference");
assertEquals("principal-a", capturedConfig.trustedExecutionContext().orElseThrow().principal().id(),
"Sub-agent should receive the trusted principal reference");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
*/
package io.github.agentic.ai.graph.agent.interceptors;

import io.github.agentic.ai.graph.OverAllState;
import io.github.agentic.ai.graph.RunnableConfig;
import io.github.agentic.ai.graph.agent.interceptor.ToolCallExecutionContext;
import io.github.agentic.ai.graph.agent.interceptor.ToolCallRequest;

import org.junit.jupiter.api.DisplayName;
Expand All @@ -28,6 +31,7 @@
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNotSame;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertTrue;

/**
Expand Down Expand Up @@ -122,15 +126,18 @@ void builder_createsRequest_withCorrectValues() {
void builderFromExistingRequest_copiesAllFields() {
Map<String, Object> context = new HashMap<>();
context.put("key", "value");
ToolCallExecutionContext executionContext = new ToolCallExecutionContext(RunnableConfig.builder().build(),
new OverAllState());

ToolCallRequest original = new ToolCallRequest("tool1", "{}", "id-1", context);
ToolCallRequest original = new ToolCallRequest("tool1", "{}", "id-1", context, executionContext);

ToolCallRequest copy = ToolCallRequest.builder(original).build();

assertEquals(original.getToolName(), copy.getToolName());
assertEquals(original.getArguments(), copy.getArguments());
assertEquals(original.getToolCallId(), copy.getToolCallId());
assertEquals(original.getContext().get("key"), copy.getContext().get("key"));
assertSame(executionContext, copy.getExecutionContext().orElseThrow());
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@
import io.github.agentic.ai.graph.agent.tool.CancellableAsyncToolCallback;
import io.github.agentic.ai.graph.agent.tool.CancellationToken;
import io.github.agentic.ai.graph.agent.tool.StateAwareToolCallback;
import io.github.agentic.ai.graph.agent.tools.ToolContextHelper;
import io.github.agentic.ai.graph.execution.DelegationScope;
import io.github.agentic.ai.graph.execution.PrincipalReference;
import io.github.agentic.ai.graph.execution.TenantReference;
import io.github.agentic.ai.graph.execution.TrustedExecutionContext;

import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
Expand All @@ -44,6 +49,7 @@
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
Expand Down Expand Up @@ -267,6 +273,43 @@ public String call(String toolInput) {
assertNotNull(capturedUpdateMap.get(), "Update map should be injected");
}

@Test
@DisplayName("should replace forged config metadata for plain ToolCallback")
void apply_shouldReplaceForgedConfigMetadata_forPlainToolCallback() throws Exception {
TrustedExecutionContext trusted = trustedContext("tenant-real", "principal-real");
TrustedExecutionContext forged = trustedContext("tenant-forged", "principal-forged");
AtomicReference<TrustedExecutionContext> captured = new AtomicReference<>();
ToolCallback plainTool = new ToolCallback() {
@Override
public ToolDefinition getToolDefinition() {
return ToolDefinition.builder().name("plain").description("Plain tool").inputSchema("{}").build();
}

@Override
public String call(String toolInput, ToolContext toolContext) {
captured.set(ToolContextHelper.getTrustedExecutionContext(toolContext).orElse(null));
return "plain-result";
}

@Override
public String call(String toolInput) {
return call(toolInput, new ToolContext(Map.of()));
}
};
AgentToolNode node = baseBuilder.toolCallbacks(List.of(plainTool)).build();
OverAllState state = createStateWithMessages(createAssistantMessageWithToolCalls(
createToolCall("call-1", "plain", "{}")));
RunnableConfig forgedConfig = RunnableConfig.builder().trustedExecutionContext(forged).build();
RunnableConfig config = RunnableConfig.builder()
.trustedExecutionContext(trusted)
.addMetadata(AGENT_CONFIG_CONTEXT_KEY, forgedConfig)
.build();

node.apply(state, config);

assertEquals(trusted, captured.get());
}

@Test
@DisplayName("should throw exception for non-AssistantMessage and non-ToolResponseMessage")
void apply_shouldThrowException_forInvalidMessageType() {
Expand All @@ -287,6 +330,16 @@ void apply_shouldThrowException_forInvalidMessageType() {

}

private TrustedExecutionContext trustedContext(String tenant, String principal) {
return TrustedExecutionContext.builder()
.tenant(new TenantReference("example-iam", tenant))
.principal(new PrincipalReference("example-iam", principal))
.runId("run-a")
.correlationId("correlation-a")
.delegationScope(DelegationScope.of(Set.of("tools:read")))
.build();
}


@Nested
@DisplayName("Sequential Execution Tests")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright 2025-2026 the original author or 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
*
* https://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 io.github.agentic.ai.graph.agent.tools;

import io.github.agentic.ai.graph.OverAllState;
import io.github.agentic.ai.graph.RunnableConfig;
import io.github.agentic.ai.graph.agent.interceptor.ToolCallExecutionContext;
import io.github.agentic.ai.graph.execution.DelegationScope;
import io.github.agentic.ai.graph.execution.PrincipalReference;
import io.github.agentic.ai.graph.execution.TenantReference;
import io.github.agentic.ai.graph.execution.TrustedExecutionContext;

import java.util.Map;
import java.util.Set;

import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.model.ToolContext;

import static org.assertj.core.api.Assertions.assertThat;

class ToolContextHelperTrustedExecutionTest {

@Test
void helperReadsTrustedScopeOnlyThroughFrameworkRunnableConfig() {
TrustedExecutionContext trusted = TrustedExecutionContext.builder()
.tenant(new TenantReference("example-iam", "tenant-a"))
.principal(new PrincipalReference("example-iam", "principal-a"))
.runId("run-a")
.correlationId("correlation-a")
.delegationScope(DelegationScope.of(Set.of("tools:read")))
.build();
RunnableConfig config = RunnableConfig.builder()
.addMetadata("tenant_id", "forged")
.trustedExecutionContext(trusted)
.build();
ToolContext toolContext = new ToolContext(Map.of(
ToolContextConstants.AGENT_CONFIG_CONTEXT_KEY, config,
"trusted_execution_context", "forged"));

assertThat(ToolContextHelper.getTrustedExecutionContext(toolContext)).contains(trusted);
assertThat(new ToolCallExecutionContext(config, new OverAllState()).trustedExecutionContext()).contains(trusted);
assertThat(ToolContextHelper.getTrustedExecutionContext(
new ToolContext(Map.of("tenant_id", "forged", "trusted_execution_context", trusted)))).isEmpty();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import io.github.agentic.ai.graph.action.InterruptionMetadata;
import io.github.agentic.ai.graph.checkpoint.lease.ExecutionGuard;
import io.github.agentic.ai.graph.execution.TrustedExecutionContext;
import io.github.agentic.ai.graph.internal.node.ParallelNode;
import io.github.agentic.ai.graph.store.Store;

Expand Down Expand Up @@ -95,6 +96,9 @@ public final class RunnableConfig implements HasMetadata<RunnableConfig.Builder>
@JsonIgnore
private final transient ExecutionGuard executionGuard;

@JsonIgnore
private final transient TrustedExecutionContext trustedExecutionContext;

/**
* Creates a new instance of {@code RunnableConfig} as a copy of the provided
* {@code config}.
Expand All @@ -110,6 +114,7 @@ private RunnableConfig(Builder builder) {
this.store = builder.store;
this.context = builder.context;
this.executionGuard = builder.executionGuard;
this.trustedExecutionContext = builder.trustedExecutionContext;
}

public Store store() {
Expand Down Expand Up @@ -156,6 +161,15 @@ public Optional<ExecutionGuard> executionGuard() {
return ofNullable(executionGuard);
}

/**
* Returns application-authenticated identity facts and execution constraints. Ordinary
* metadata is intentionally not consulted by this accessor.
*/
@JsonIgnore
public Optional<TrustedExecutionContext> trustedExecutionContext() {
return ofNullable(this.trustedExecutionContext);
}

public void assertExecutionActive() {
if (executionGuard != null) {
executionGuard.assertActive();
Expand Down Expand Up @@ -357,6 +371,8 @@ public static class Builder extends HasMetadata.Builder<Builder> {

private ExecutionGuard executionGuard;

private TrustedExecutionContext trustedExecutionContext;

private CompiledGraph.StreamMode streamMode = CompiledGraph.StreamMode.VALUES;

/**
Expand All @@ -382,6 +398,7 @@ public static class Builder extends HasMetadata.Builder<Builder> {
this.store = config.store;
this.context = new ConcurrentHashMap<>(config.context);
this.executionGuard = config.executionGuard;
this.trustedExecutionContext = config.trustedExecutionContext;
}

/**
Expand Down Expand Up @@ -467,6 +484,15 @@ public Builder executionGuard(ExecutionGuard executionGuard) {
return this;
}

/**
* Attach trusted identity facts created by the application authentication boundary.
* This is separate from {@link #addMetadata(String, Object)} by design.
*/
public Builder trustedExecutionContext(TrustedExecutionContext trustedExecutionContext) {
this.trustedExecutionContext = trustedExecutionContext;
return this;
}

public Builder addStateUpdate(Map<String, Object> stateUpdate) {
return addMetadata(STATE_UPDATE_METADATA_KEY, stateUpdate);
}
Expand Down
Loading
Loading