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
26 changes: 21 additions & 5 deletions agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java
Original file line number Diff line number Diff line change
Expand Up @@ -723,6 +723,12 @@ protected Object beforeAgentExecution(List<Msg> msgs, RuntimeContext rc) {
// by consumeSystemMsgAfterPreCall; the event sink (if any) is bound in doCall() from the
// per-subscription Reactor Context carried by streamEvents.
scope.rc = ctx;
// Per-call toolkit: prefer the override carried on the RuntimeContext, fall back to the
// shared field. Resolved once here so the whole call (schema gen / permission / execution)
// observes a single, stable toolkit — concurrent calls on this agent never see each other's
// per-call toolkit.
Toolkit perCallToolkit = ctx.getToolkit();
scope.activeToolkit = perCallToolkit != null ? perCallToolkit : this.toolkit;
scope.systemMsg = null;
// Clear any stale interrupt signal for this session before the new call begins.
scope.state.interruptControl().reset();
Expand Down Expand Up @@ -1649,6 +1655,15 @@ final class CallExecution {
*/
RuntimeContext rc;

/**
* Per-call resolved toolkit: prefers the {@code toolkit} carried on {@link #rc} (per-call
* override) and falls back to the enclosing agent's shared {@code toolkit} field. Resolved
* once in {@code beforeAgentExecution} and stable for the whole call, so concurrent calls
* on the same agent never observe each other's per-call overrides. All toolkit access in
* this scope reads {@code activeToolkit} rather than the shared field.
*/
Toolkit activeToolkit;

/**
* Per-call structured-output tool (the {@code generate_response} tool). Non-null only for
* fallback structured-output calls (when the model does not support native structured
Expand Down Expand Up @@ -2307,7 +2322,7 @@ private Mono<Msg> reasoning(int iter, boolean ignoreMaxIters) {
prependSystemMsg(
event.getInputMessages(), event.getSystemMessage());
List<ToolSchema> tools =
toolkit.getToolSchemas(
activeToolkit.getToolSchemas(
state.getToolContext().getActivatedGroups());
// Per-call structured-output tool: expose generate_response to the
// model for this call only (not registered on the shared toolkit).
Expand Down Expand Up @@ -2953,7 +2968,7 @@ private Flux<AgentEvent> runToolBatch(
Set<String> chunkedToolIds =
ConcurrentHashMap.newKeySet();

toolkit.setInternalChunkCallback(
activeToolkit.setInternalChunkCallback(
(toolUse, chunk) -> {
if (chunk.getOutput() != null
&& !chunk.getOutput()
Expand Down Expand Up @@ -3122,7 +3137,7 @@ private Mono<PermissionVerdict> evaluateOne(ToolUseBlock use, boolean useEngine)
if (use.getState() == ToolCallState.ALLOWED) {
return Mono.just(new PermissionVerdict(use, PermissionBehavior.ALLOW));
}
AgentTool tool = toolkit.getTool(use.getName());
AgentTool tool = activeToolkit.getTool(use.getName());
if (!(tool instanceof ToolBase tb)) {
return Mono.just(new PermissionVerdict(use, PermissionBehavior.ALLOW));
}
Expand Down Expand Up @@ -3327,7 +3342,7 @@ private Mono<List<ToolResultBlock>> dispatchToolCalls(List<ToolUseBlock> toolCal
&& toolCalls.stream()
.anyMatch(t -> STRUCTURED_OUTPUT_TOOL_NAME.equals(t.getName()));
if (!hasStructured) {
return toolkit.callTools(
return activeToolkit.callTools(
toolCalls,
toolExecutionConfig,
ReActAgent.this,
Expand All @@ -3341,7 +3356,8 @@ private Mono<List<ToolResultBlock>> dispatchToolCalls(List<ToolUseBlock> toolCal
Mono<Map<String, ToolResultBlock>> regularResults =
regular.isEmpty()
? Mono.just(Map.of())
: toolkit.callTools(
: activeToolkit
.callTools(
regular,
toolExecutionConfig,
ReActAgent.this,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import io.agentscope.core.state.AgentState;
import io.agentscope.core.tool.ContextStore;
import io.agentscope.core.tool.ToolExecutionContext;
import io.agentscope.core.tool.Toolkit;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
Expand Down Expand Up @@ -56,13 +57,21 @@ public class RuntimeContext {

private final ToolExecutionContext toolExecutionContext;

/**
* Per-call toolkit override. When non-null, the agent's execution engine uses this toolkit for
* the duration of the call instead of the agent's shared {@code toolkit} field. {@code null}
* means fall back to the shared field.
*/
private Toolkit toolkit;

private RuntimeContext(Builder builder) {
this.sessionId = builder.sessionId;
this.userId = builder.userId;
this.stringAttributes = new ConcurrentHashMap<>();
this.typedAttributes = new ConcurrentHashMap<>();
this.toolExecutionContext = builder.toolExecutionContext;
this.agentState = builder.agentState;
this.toolkit = builder.toolkit;
if (builder.stringExtras != null) {
this.stringAttributes.putAll(builder.stringExtras);
}
Expand Down Expand Up @@ -142,6 +151,23 @@ public ToolExecutionContext getToolExecutionContext() {
return toolExecutionContext;
}

/**
* Returns the per-call toolkit override, or {@code null} when none was provided (the execution
* engine then falls back to the agent's shared toolkit field).
*/
public Toolkit getToolkit() {
return toolkit;
}

/**
* Installs the per-call toolkit override. Intended for callers that need to vary the toolkit
* per call without mutating the agent's shared field (concurrency-safe). Set to {@code null} to
* fall back to the shared field.
*/
public void setToolkit(Toolkit toolkit) {
this.toolkit = toolkit;
}

@SuppressWarnings("unchecked")
public <T> T get(String key) {
if (key == null) {
Expand Down Expand Up @@ -329,6 +355,7 @@ public static class Builder {
private final Map<Class<?>, Map<String, Object>> typedValues = new HashMap<>();
private ToolExecutionContext toolExecutionContext;
private AgentState agentState;
private Toolkit toolkit;

public Builder sessionId(String sessionId) {
this.sessionId = sessionId;
Expand Down Expand Up @@ -384,6 +411,7 @@ public Builder from(RuntimeContext source) {
this.userId = source.userId;
this.agentState = source.agentState;
this.toolExecutionContext = source.toolExecutionContext;
this.toolkit = source.toolkit;
if (!source.stringAttributes.isEmpty()) {
this.stringExtras = new ConcurrentHashMap<>(source.stringAttributes);
}
Expand All @@ -406,6 +434,16 @@ public Builder toolExecutionContext(ToolExecutionContext toolExecutionContext) {
return this;
}

/**
* Installs a per-call toolkit override. When set, the agent's execution engine uses this
* toolkit for the duration of the call instead of the agent's shared {@code toolkit} field
* (concurrency-safe). {@code null} (the default) means fall back to the shared field.
*/
public Builder toolkit(Toolkit toolkit) {
this.toolkit = toolkit;
return this;
}

public RuntimeContext build() {
return new RuntimeContext(this);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@ private static final class SharedPojo {
}
}

/** Tool that only exists on the per-call toolkit, not on the shared one. */
private static class PerCallOnlyTool {
@Tool(description = "Probe that only exists on the per-call toolkit")
public String per_call_probe(@ToolParam(name = "q", description = "q") String q) {
return "per-call:" + q;
}
}

private static class CtxTools {
@Tool(description = "Read RuntimeContext in a tool call")
public String ctx_probe(
Expand Down Expand Up @@ -148,6 +156,63 @@ void awareHookAndToolContext() {
.anyMatch(m -> m.hasContentBlocks(ToolResultBlock.class)));
}

@Test
@DisplayName("RuntimeContext.toolkit overrides the agent's shared toolkit for the call")
void perCallToolkitOverrideUsedByCallExecution() {
// Shared toolkit: only ctx_probe. Per-call toolkit: ctx_probe + per_call_probe.
Toolkit shared = new Toolkit();
shared.registerTool(new CtxTools());
Toolkit perCall = shared.copy();
perCall.registerTool(new PerCallOnlyTool());

final int[] modelRound = {0};
MockModel model =
new MockModel(
messages -> {
if (modelRound[0]++ == 0) {
return List.of(
createToolResponse(
"per_call_probe", "pc1", Map.of("q", "hello")));
}
return List.of(
ChatResponse.builder()
.content(
List.of(
TextBlock.builder()
.text("done")
.build()))
.usage(new ChatUsage(1, 1, 0))
.build());
});

ReActAgent agent =
ReActAgent.builder()
.name(TestConstants.TEST_REACT_AGENT_NAME)
.sysPrompt(TestConstants.DEFAULT_SYS_PROMPT)
.model(model)
.toolkit(shared)
.build();

// Sanity: shared toolkit does NOT have the per-call-only tool.
assertNull(shared.getTool("per_call_probe"));
assertNotNull(perCall.getTool("per_call_probe"));

RuntimeContext rc =
RuntimeContext.builder().userId("per-call-uid").toolkit(perCall).build();
Msg user = TestUtils.createUserMessage("User", "probe");
Msg out =
agent.call(List.of(user), rc)
.block(Duration.ofMillis(TestConstants.DEFAULT_TEST_TIMEOUT_MS));

assertNotNull(out);
// If activeToolkit fell back to the shared field, the tool lookup would fail and the
// result text would be an error message rather than "per-call:hello".
String toolOut = lastToolText(agent, "per-call-uid", null);
assertTrue(
toolOut.contains("per-call:hello"),
"per-call toolkit should have been used, got: " + toolOut);
}

@Test
void buildMergedRuntimeContextCopiesTypedData() throws Exception {
ReActAgent agent =
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,6 @@
import io.agentscope.core.message.ToolResultBlock;
import io.agentscope.core.message.ToolUseBlock;
import io.agentscope.core.model.ToolSchema;
import io.agentscope.core.tool.AgentTool;
import io.agentscope.core.tool.SchemaOnlyTool;
import io.agentscope.core.tool.Toolkit;
import java.lang.reflect.InvocationTargetException;
Expand Down Expand Up @@ -156,19 +155,15 @@ public Flux<AguiEvent> run(RunAgentInput input, RuntimeContext runtimeContext) {
.incremental(true)
.build();

ToolInjection toolInjection = ToolInjection.empty();
AgentStream agentStream;
try {
toolInjection = injectFrontendTools(input);
agentStream =
streamWithRuntimeContext(
msgs, options, effectiveRuntimeContext, input);
} catch (Throwable error) {
toolInjection.close();
return errorEvents(threadId, runId, input, error, true);
}

ToolInjection activeToolInjection = toolInjection;
AtomicBoolean eventSeen = new AtomicBoolean(false);

return Flux.concat(
Expand All @@ -179,7 +174,6 @@ public Flux<AguiEvent> run(RunAgentInput input, RuntimeContext runtimeContext) {
eventSeen.set(true);
}),
Flux.defer(() -> agentStream.finish().get()))
.doFinally(signalType -> activeToolInjection.close())
.onErrorResume(
error ->
errorEvents(
Expand Down Expand Up @@ -289,16 +283,21 @@ private static boolean isHarnessAgent(Agent agent) {
* Build the runtime context used for the agent invocation.
*
* <p>The caller-provided context is copied first, then AG-UI protocol metadata is applied so
* that required request values and session isolation are always preserved.
* that required request values and session isolation are always preserved. A per-call toolkit
* is built from a {@linkplain Toolkit#copy() deep copy} of the agent's shared toolkit and
* carried via {@link RuntimeContext#getToolkit()} so the shared field is never mutated and
* concurrent runs on the same agent are isolated.
*
* @param input The AG-UI run input
* @param runtimeContext Optional caller-provided runtime context
* @return The effective runtime context for this run
*/
protected RuntimeContext buildRuntimeContext(
RunAgentInput input, RuntimeContext runtimeContext) {
Toolkit perCallToolkit = buildPerCallToolkit(input);
return RuntimeContext.builder(runtimeContext)
.sessionId(input.getThreadId())
.toolkit(perCallToolkit)
.put(RunAgentInput.class, input)
.put(RUNTIME_CONTEXT_THREAD_ID_KEY, input.getThreadId())
.put(RUNTIME_CONTEXT_RUN_ID_KEY, input.getRunId())
Expand Down Expand Up @@ -330,48 +329,48 @@ private Map<String, AguiEvent.Interrupt> resumeInterrupts(RuntimeContext runtime
return Map.copyOf(interrupts);
}

private ToolInjection injectFrontendTools(RunAgentInput input) {
/**
* Builds a per-call toolkit based on the frontend tools carried by {@code input}.
*
* <p>Operates on a {@linkplain Toolkit#copy() deep copy} of the agent's shared toolkit, so the
* shared field is never mutated and concurrent runs on the same agent are isolated. The
* resulting toolkit is carried via {@link RuntimeContext#getToolkit()}.
*
* @return a per-call toolkit, or {@code null} when no override is needed (the execution engine
* then falls back to the agent's shared toolkit)
*/
private Toolkit buildPerCallToolkit(RunAgentInput input) {
if (!input.hasTools()) {
return ToolInjection.empty();
return null;
}

ToolMergeMode mergeMode =
config.getToolMergeMode() != null
? config.getToolMergeMode()
: ToolMergeMode.MERGE_FRONTEND_PRIORITY;
if (mergeMode == ToolMergeMode.AGENT_ONLY) {
return ToolInjection.empty();
return null;
}

Toolkit toolkit = agent.getToolkit();
if (toolkit == null) {
return ToolInjection.empty();
Toolkit source = agent.getToolkit();
if (source == null) {
return null;
}

Map<String, AgentTool> previousTools = new LinkedHashMap<>();
// Deep copy: mutate only the copy, never the shared toolkit.
Toolkit perCallToolkit = source.copy();

if (mergeMode == ToolMergeMode.FRONTEND_ONLY) {
for (String toolName : toolkit.getToolNames()) {
AgentTool previousTool = toolkit.getTool(toolName);
if (previousTool != null) {
previousTools.put(toolName, previousTool);
toolkit.removeTool(toolName);
}
for (String toolName : perCallToolkit.getToolNames()) {
perCallToolkit.removeTool(toolName);
}
}

List<SchemaOnlyTool> registeredTools = new ArrayList<>();
for (ToolSchema schema : toolConverter.toToolSchemaList(input.getTools())) {
AgentTool previousTool = toolkit.getTool(schema.getName());
if (previousTool != null) {
previousTools.putIfAbsent(schema.getName(), previousTool);
}

SchemaOnlyTool frontendTool = new SchemaOnlyTool(schema);
toolkit.registerAgentTool(frontendTool);
registeredTools.add(frontendTool);
perCallToolkit.registerAgentTool(new SchemaOnlyTool(schema));
}

return new ToolInjection(toolkit, registeredTools, previousTools);
return perCallToolkit;
}

private Flux<AguiEvent> errorEvents(
Expand Down
Loading
Loading