From 53ea9f884f025e14590026a2437c061982f68a7d Mon Sep 17 00:00:00 2001 From: Chickenlj Date: Wed, 10 Jun 2026 09:44:44 +0800 Subject: [PATCH 01/11] feat(agent): enhance agent lifecycle with new toolkit access and event streaming capabilities --- .../java/io/agentscope/core/ReActAgent.java | 227 ++++++++++++------ .../java/io/agentscope/core/agent/Agent.java | 15 ++ .../io/agentscope/core/agent/AgentBase.java | 32 ++- .../io/agentscope/core/event/AgentEvent.java | 1 + .../agentscope/core/event/AgentEventType.java | 1 + .../core/event/AgentResultEvent.java | 60 +++++ .../UserIsolatedMultiTurnsExample.java | 88 +++++++ .../harness/agent/HarnessAgent.java | 15 +- .../filesystem/spec/RemoteFilesystemSpec.java | 9 +- .../middleware/HarnessSkillMiddleware.java | 21 +- .../middleware/MemoryFlushMiddleware.java | 75 +++++- .../MemoryMaintenanceMiddleware.java | 66 ++++- .../agent/workspace/plan/PlanModeManager.java | 4 +- .../MemoryFlushMiddlewareTriggerTest.java | 146 ++++++++++- 14 files changed, 646 insertions(+), 114 deletions(-) create mode 100644 agentscope-core/src/main/java/io/agentscope/core/event/AgentResultEvent.java create mode 100644 agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/quickstart/UserIsolatedMultiTurnsExample.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index a9d4a86809..6fbb9989a4 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -21,12 +21,14 @@ import io.agentscope.core.agent.Event; import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.agent.StreamOptions; +import io.agentscope.core.agent.SubagentEventBus; import io.agentscope.core.agent.accumulator.ReasoningContext; import io.agentscope.core.agent.config.ModelConfig; import io.agentscope.core.agent.config.ReactConfig; import io.agentscope.core.event.AgentEndEvent; import io.agentscope.core.event.AgentEvent; import io.agentscope.core.event.AgentEventEmitter; +import io.agentscope.core.event.AgentResultEvent; import io.agentscope.core.event.AgentStartEvent; import io.agentscope.core.event.ConfirmResult; import io.agentscope.core.event.ExceedMaxItersEvent; @@ -603,15 +605,15 @@ private RuntimeContext buildMergedRuntimeContext(RuntimeContext run) { * persisted). */ public Mono call(List msgs, RuntimeContext context) { - return withRuntimeContext(call(msgs), context); + return callInternal(msgs, context, this::doCall); } public Mono call(List msgs, Class structuredOutputClass, RuntimeContext context) { - return withRuntimeContext(call(msgs, structuredOutputClass), context); + return callInternal(msgs, context, m -> doCall(m, structuredOutputClass)); } public Mono call(List msgs, JsonNode outputSchema, RuntimeContext context) { - return withRuntimeContext(call(msgs, outputSchema), context); + return callInternal(msgs, context, m -> doCall(m, outputSchema)); } /** @@ -732,43 +734,46 @@ public Flux stream( return withRuntimeContext(stream(msgs, options, schema), context); } - /** - * Stream fine-grained {@link AgentEvent}s from the full agent lifecycle. - * - *

This method goes through the same lifecycle as {@code call()} (acquire execution, - * hooks, pre/post call notification) but exposes the internal event stream. The lifecycle - * is driven by {@code call()} internally; events are captured via the per-call - * {@link CallExecution#eventSink} (carried on the per-subscription Reactor Context). - * - * @param msgs input messages - * @return event stream covering the full agent invocation lifecycle - */ - public Flux streamEvents(List msgs) { - return streamEvents(msgs, (RuntimeContext) null); - } + // ==================== Shared agent-stream core ==================== /** - * Stream fine-grained {@link AgentEvent}s for a single input message. + * Overrides the base-class hook so that every {@code call()} variant — including structured + * output and context-bearing overloads — runs through the same {@link #buildAgentStream} core + * as {@code streamEvents()}. This guarantees that the {@code onAgent} middleware chain fires + * on all invocation paths, not only on the streaming path. * - * @param msg input message - * @return event stream covering the full agent invocation lifecycle + *

The result is extracted from the {@link AgentResultEvent} emitted by + * {@link #buildAgentStream} before {@link AgentEndEvent}. */ - public Flux streamEvents(Msg msg) { - return streamEvents(List.of(msg)); + @Override + protected Mono callInternal( + List msgs, RuntimeContext context, Function, Mono> doCallFn) { + return buildAgentStream(msgs, context, doCallFn) + .filter(e -> e instanceof AgentResultEvent) + .cast(AgentResultEvent.class) + .map(AgentResultEvent::getResult) + .takeLast(1) + .next(); } /** - * Stream fine-grained {@link AgentEvent}s with a caller-supplied {@link RuntimeContext}. + * Single implementation shared by both {@code call()} (via {@link #callInternal}) and + * {@code streamEvents()}. * - *

Mirrors the {@code call(msgs, context)} overload: the supplied context is attached to the - * Reactor Context of the underlying {@code call()} invocation that drives the event stream, so - * concurrent {@code streamEvents} calls do not share it. + *

The stream is bookended by {@link AgentStartEvent} / {@link AgentEndEvent}, wraps the + * full {@link AgentBase#runLifecycle} (shutdown guard, serialization gate, pre/post hooks, + * tracing), and emits {@link AgentResultEvent} carrying the final {@link Msg} immediately + * before {@link AgentEndEvent}. The {@code onAgent} middleware chain is applied exactly + * once around this core. * - * @param msgs input messages - * @param context runtime context to propagate into the call + * @param msgs input messages + * @param context caller-supplied per-call {@link RuntimeContext}, or {@code null} + * @param doCallFn the concrete call implementation ({@link #doCall} or a structured-output + * variant) passed straight through to {@link AgentBase#runLifecycle} * @return event stream covering the full agent invocation lifecycle */ - public Flux streamEvents(List msgs, RuntimeContext context) { + private Flux buildAgentStream( + List msgs, RuntimeContext context, Function, Mono> doCallFn) { String replyId = UUID.randomUUID().toString().replace("-", ""); Function> core = input -> @@ -777,29 +782,95 @@ public Flux streamEvents(List msgs, RuntimeContext context) { sink.next(new AgentStartEvent(null, replyId, getName())); reactor.util.context.Context subscriberCtx = reactor.util.context.Context.of(sink.contextView()); - // Carry the per-subscription event sink on the Reactor Context - // so doCall() can bind it onto this call's CallExecution scope; - // concurrent streamEvents calls never share an instance field. - withRuntimeContext(call(input.msgs()), context) + + // Call runLifecycle directly — NOT call() — to avoid the + // onAgent chain being applied a second time. + Mono lifecycle = runLifecycle(input.msgs(), doCallFn); + if (context != null) { + lifecycle = + lifecycle.contextWrite( + c -> c.put(RUNTIME_CONTEXT_KEY, context)); + } + // Do not install AgentEventEmitter.CONTEXT_KEY when the + // deprecated stream() → SubagentEventBus path is driving + // this invocation. On that path AgentSpawnTool reads + // SubagentEventBus.CONTEXT_KEY to forward child events; + // installing CONTEXT_KEY here would cause execLocalSync to + // take the AgentEvent path instead of the bus path, routing + // child events into this Flux's internal sink where they get + // filtered out by callInternal before reaching the caller. + boolean isSubagentBusPath = + subscriberCtx.hasKey(SubagentEventBus.CONTEXT_KEY); + lifecycle .contextWrite(c -> c.put(EVENT_SINK_KEY, sink)) .contextWrite( c -> - c.put( - AgentEventEmitter.CONTEXT_KEY, - (AgentEventEmitter) sink::next)) + isSubagentBusPath + ? c + : c.put( + AgentEventEmitter + .CONTEXT_KEY, + (AgentEventEmitter) + sink::next)) .doFinally( signal -> { sink.next(new AgentEndEvent(replyId)); sink.complete(); }) .contextWrite(subscriberCtx) - .subscribe(finalMsg -> {}, sink::error); + .subscribe( + finalMsg -> + sink.next( + new AgentResultEvent(finalMsg)), + sink::error); }, FluxSink.OverflowStrategy.BUFFER); return MiddlewareChain.build(middlewares, this, context, MiddlewareBase::onAgent, core) .apply(new AgentInput(msgs == null ? List.of() : msgs)); } + // ==================== streamEvents public API ==================== + + /** + * Stream fine-grained {@link AgentEvent}s from the full agent lifecycle. + * + *

Both {@code call()} and {@code streamEvents()} share the same internal + * {@link #buildAgentStream} core, so the {@code onAgent} middleware chain fires on all paths. + * The stream includes {@link AgentResultEvent} (carrying the final {@link Msg}) immediately + * before {@link AgentEndEvent}. + * + * @param msgs input messages + * @return event stream covering the full agent invocation lifecycle + */ + public Flux streamEvents(List msgs) { + return streamEvents(msgs, (RuntimeContext) null); + } + + /** + * Stream fine-grained {@link AgentEvent}s for a single input message. + * + * @param msg input message + * @return event stream covering the full agent invocation lifecycle + */ + public Flux streamEvents(Msg msg) { + return streamEvents(List.of(msg)); + } + + /** + * Stream fine-grained {@link AgentEvent}s with a caller-supplied {@link RuntimeContext}. + * + *

Delegates directly to {@link #buildAgentStream} — the same core used by {@code call()}. + * Concurrent invocations do not share any state; each subscription gets its own event sink and + * lifecycle execution. + * + * @param msgs input messages + * @param context runtime context to propagate into the call + * @return event stream covering the full agent invocation lifecycle + */ + public Flux streamEvents(List msgs, RuntimeContext context) { + return buildAgentStream(msgs, context, this::doCall); + } + /** * Stream fine-grained {@link AgentEvent}s for a single input message with a caller-supplied * {@link RuntimeContext}. @@ -1557,48 +1628,6 @@ private void maybePatchPendingToolCalls(List msgs) { } } - /** - * Execute the full agent invocation as a {@link Flux} of fine-grained {@link AgentEvent}s. - * - *

This method wraps the existing {@code doCall()} logic and captures all events emitted - * by the internal stream methods ({@code reasoningStream}, {@code actingStream}, - * {@code summaryStream}). The stream is bookended by {@link AgentStartEvent} and - * {@link AgentEndEvent}. - * - * @param msgs the input messages - * @return event stream covering the full agent invocation lifecycle - */ - Flux agentImpl(List msgs) { - String replyId = UUID.randomUUID().toString().replace("-", ""); - - Function> core = - input -> - Flux.create( - sink -> { - eventSink = sink; - sink.next( - new AgentStartEvent( - null, replyId, getName())); - - doCall(input.msgs()) - .doFinally( - signal -> { - sink.next( - new AgentEndEvent( - replyId)); - eventSink = null; - sink.complete(); - }) - .subscribe(finalMsg -> {}, sink::error); - }, - FluxSink.OverflowStrategy.BUFFER) - .doOnError(e -> eventSink = null); - - return MiddlewareChain.build( - middlewares, ReActAgent.this, rc, MiddlewareBase::onAgent, core) - .apply(new AgentInput(msgs)); - } - private void publishEvent(AgentEvent event) { FluxSink sink = eventSink; if (sink != null) { @@ -2355,9 +2384,15 @@ private Flux runToolBatch( tool.getName())); } + Set chunkedToolIds = + ConcurrentHashMap.newKeySet(); + toolkit.setInternalChunkCallback( (toolUse, chunk) -> { - if (chunk.getOutput() != null) { + if (chunk.getOutput() != null + && !chunk.getOutput() + .isEmpty()) { + chunkedToolIds.add(toolUse.getId()); for (ContentBlock block : chunk.getOutput()) { if (block @@ -2403,6 +2438,11 @@ private Flux runToolBatch( ToolUseBlock, ToolResultBlock> entry : results) { + emitToolResultDelta( + sink, + replyId, + entry, + chunkedToolIds); ToolResultState state = determineToolResultState( entry @@ -2509,6 +2549,33 @@ private Mono evaluateOne(ToolUseBlock use, boolean useEngine) private record PermissionVerdict(ToolUseBlock use, PermissionBehavior behavior) {} + /** + * Emit delta events for tool results that were NOT already streamed via the chunk + * callback. For non-streaming tools the chunk callback is never invoked, so the + * event stream would otherwise contain only START and END with no content. + */ + private void emitToolResultDelta( + FluxSink sink, + String replyId, + Map.Entry entry, + Set chunkedToolIds) { + String toolId = entry.getKey().getId(); + if (chunkedToolIds.contains(toolId)) { + return; + } + List output = entry.getValue().getOutput(); + if (output == null || output.isEmpty()) { + return; + } + for (ContentBlock block : output) { + if (block instanceof TextBlock tb) { + sink.next(new ToolResultTextDeltaEvent(replyId, toolId, tb.getText())); + } else { + sink.next(new ToolResultDataDeltaEvent(replyId, toolId, block)); + } + } + } + private ToolResultState determineToolResultState(ToolResultBlock result) { if (result.isSuspended()) { return ToolResultState.RUNNING; diff --git a/agentscope-core/src/main/java/io/agentscope/core/agent/Agent.java b/agentscope-core/src/main/java/io/agentscope/core/agent/Agent.java index 6aa19cc05f..438d1a32f7 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/agent/Agent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/Agent.java @@ -16,6 +16,7 @@ package io.agentscope.core.agent; import io.agentscope.core.message.Msg; +import io.agentscope.core.tool.Toolkit; /** * Complete agent interface combining all capabilities. @@ -96,4 +97,18 @@ default String getDescription() { default io.agentscope.core.state.AgentState getAgentState() { return null; } + + /** + * Returns the agent's live {@link Toolkit}, or {@code null} if this agent type does not + * maintain one. + * + *

This is the runtime toolkit — the same instance the agent uses when listing + * available tools for the model and dispatching tool calls. Middleware that needs to register + * tools dynamically (e.g., skill loaders) must use this accessor rather than any toolkit + * reference captured at build time, because agents may deep-copy the toolkit during + * construction. + */ + default Toolkit getToolkit() { + return null; + } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/agent/AgentBase.java b/agentscope-core/src/main/java/io/agentscope/core/agent/AgentBase.java index 8b48494be8..c1ff7385a1 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/agent/AgentBase.java +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/AgentBase.java @@ -188,7 +188,31 @@ public final boolean isCheckRunning() { */ @Override public final Mono call(List msgs) { - return runLifecycle(msgs, this::doCall); + return callInternal(msgs, null, this::doCall); + } + + /** + * Extension point called by every {@code call()} overload, allowing subclasses to wrap the + * entire invocation in additional middleware (e.g. the {@code onAgent} chain in + * {@code ReActAgent}). + * + *

The default implementation attaches {@code context} to the Reactor Context (when + * non-null) and delegates straight to {@link #runLifecycle}. Subclasses that override this + * method must eventually invoke {@code runLifecycle(msgs, doCallFn)} to run the standard + * lifecycle (shutdown guard, serialization gate, pre/post hooks, tracing). + * + * @param msgs input messages + * @param context caller-supplied per-call {@link RuntimeContext}, or {@code null} + * @param doCallFn the concrete call implementation ({@link #doCall} or a structured-output + * variant) + * @return response message + */ + protected Mono callInternal( + List msgs, RuntimeContext context, Function, Mono> doCallFn) { + Mono lifecycle = runLifecycle(msgs, doCallFn); + return context == null + ? lifecycle + : lifecycle.contextWrite(c -> c.put(RUNTIME_CONTEXT_KEY, context)); } /** @@ -225,7 +249,7 @@ public final Mono call(List msgs) { * that scope on the Reactor Context, and run the preCall → doCall → postCall chain with error * handling, releasing execution on terminate. */ - private Mono runLifecycle(List msgs, Function, Mono> doCallFn) { + protected Mono runLifecycle(List msgs, Function, Mono> doCallFn) { return Mono.using( this::acquireExecution, resource -> @@ -351,7 +375,7 @@ private Mono serializeOnKey(Object key, Mono action) { */ @Override public final Mono call(List msgs, Class structuredOutputClass) { - return runLifecycle(msgs, m -> doCall(m, structuredOutputClass)); + return callInternal(msgs, null, m -> doCall(m, structuredOutputClass)); } /** @@ -365,7 +389,7 @@ public final Mono call(List msgs, Class structuredOutputClass) { */ @Override public final Mono call(List msgs, JsonNode schema) { - return runLifecycle(msgs, m -> doCall(m, schema)); + return callInternal(msgs, null, m -> doCall(m, schema)); } /** diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java index 3a8b0b35a7..ef152bc238 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java @@ -34,6 +34,7 @@ @JsonSubTypes({ @JsonSubTypes.Type(value = AgentStartEvent.class, name = "AGENT_START"), @JsonSubTypes.Type(value = AgentEndEvent.class, name = "AGENT_END"), + @JsonSubTypes.Type(value = AgentResultEvent.class, name = "AGENT_RESULT"), @JsonSubTypes.Type(value = ModelCallStartEvent.class, name = "MODEL_CALL_START"), @JsonSubTypes.Type(value = ModelCallEndEvent.class, name = "MODEL_CALL_END"), @JsonSubTypes.Type(value = TextBlockStartEvent.class, name = "TEXT_BLOCK_START"), diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java index 0b6d483cb1..0149d18af4 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java @@ -42,6 +42,7 @@ public enum AgentEventType { AGENT_START("AGENT_START"), @JsonAlias({"RUN_FINISHED", "REPLY_END"}) AGENT_END("AGENT_END"), + AGENT_RESULT("AGENT_RESULT"), @JsonAlias({"MODEL_CALL_STARTED"}) MODEL_CALL_START("MODEL_CALL_START"), diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentResultEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentResultEvent.java new file mode 100644 index 0000000000..fa920ced1d --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentResultEvent.java @@ -0,0 +1,60 @@ +/* + * Copyright 2024-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 + * + * 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 io.agentscope.core.event; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.agentscope.core.message.Msg; + +/** + * Emitted when an agent successfully finishes processing an invocation, carrying the final result + * message. + * + *

This event is emitted as part of the agent event stream (both {@code call()} and + * {@code streamEvents()} paths) immediately before {@link AgentEndEvent}. Callers of + * {@code streamEvents()} can filter for this event to obtain the final {@link Msg} directly + * from the event stream without subscribing to the {@code Mono} return value separately. + * + *

{@code call()} internally uses this event to extract the result from the shared + * {@code buildAgentStream()} core, ensuring both paths run through the same {@code onAgent} + * middleware chain. + */ +public class AgentResultEvent extends AgentEvent { + + private final Msg result; + + public AgentResultEvent(Msg result) { + this.result = result; + } + + @JsonCreator + public AgentResultEvent( + @JsonProperty("id") String id, + @JsonProperty("createdAt") String createdAt, + @JsonProperty("result") Msg result) { + super(id, createdAt); + this.result = result; + } + + @Override + public AgentEventType getType() { + return AgentEventType.AGENT_RESULT; + } + + public Msg getResult() { + return result; + } +} diff --git a/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/quickstart/UserIsolatedMultiTurnsExample.java b/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/quickstart/UserIsolatedMultiTurnsExample.java new file mode 100644 index 0000000000..1398f067c0 --- /dev/null +++ b/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/quickstart/UserIsolatedMultiTurnsExample.java @@ -0,0 +1,88 @@ +package io.agentscope.examples.documentation2.quickstart; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.core.model.Model; +import io.agentscope.harness.agent.HarnessAgent; +import io.agentscope.harness.agent.memory.compaction.CompactionConfig; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; + +public class UserIsolatedMultiTurnsExample { + + public static void main(String[] args) throws Exception { + // 1. Prepare workspace: generate AGENTS.md on first run, reuse afterwards + Path workspace = Paths.get(".agentscope/workspace"); + initWorkspaceIfAbsent(workspace); + + // 2. Build model + Model model = DashScopeChatModel.builder() + .apiKey(System.getenv("DASHSCOPE_API_KEY")) + .modelName("qwen-max") + .stream(true) + .build(); + + // 3. Build HarnessAgent: workspace injection, session persistence, and trace logging + // are enabled by default; compaction is explicitly configured here + HarnessAgent agent = HarnessAgent.builder() + .name("quickstart-agent") + .sysPrompt("You are a note-taking assistant.") + .model(model) + .workspace(workspace) + .compaction(CompactionConfig.builder() + .triggerMessages(30) + .keepMessages(10) + .flushBeforeCompact(true) // extract facts to daily log before compacting + .build()) + .build(); + + // 4. Two conversation turns with the same RuntimeContext + // Same sessionId → turn 2 auto-restores state from turn 1 + RuntimeContext ctx = RuntimeContext.builder() + .sessionId("demo-session") + .userId("alice") + .build(); + + Msg turn1 = agent.call( + Msg.builder().role(MsgRole.USER) + .textContent("My name is Alice, and I'm preparing a tech talk on ReAct today. Remember this!") + .build(), + ctx).block(); + System.out.println("[turn1] " + turn1.getTextContent()); + +// + ctx = RuntimeContext.builder() + .sessionId("demo-session") + .userId("ken") + .build(); + Msg turn2 = agent.call( + Msg.builder().role(MsgRole.USER) + .textContent("I like eating apples. Remember this!") + .build(), + ctx).block(); + System.out.println("[turn2] " + turn2.getTextContent()); + + // wait 5 seconds for asynchronous memory flush to write + Thread.sleep(5000); + } + + private static void initWorkspaceIfAbsent(Path workspace) throws Exception { + Files.createDirectories(workspace); + Path agentsMd = workspace.resolve("AGENTS.md"); + if (Files.exists(agentsMd)) return; + Files.writeString(agentsMd, """ + # Note-taking Assistant + + You are an assistant that helps users organize notes and knowledge. + + ## Behavior Guidelines + - Actively record key facts the user mentions (names, plans, preferences, etc.) + - Reply concisely, using bullet lists when helpful + - For uncertain information, say so clearly rather than guessing + """); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java index 33c717a56a..170c18ae57 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java @@ -1905,6 +1905,15 @@ public HarnessAgent build() { } Model memoryModel = memoryConfig.model() != null ? memoryConfig.model() : model; if (memoryModel != null && !disableMemoryHooks) { + IsolationScope effectiveIsolationScope = IsolationScope.USER; + if (remoteFilesystemSpec != null + && remoteFilesystemSpec.getIsolationScope() != null) { + effectiveIsolationScope = remoteFilesystemSpec.getIsolationScope(); + } else if (sandboxFilesystemSpec != null + && sandboxFilesystemSpec.getIsolationScope() != null) { + effectiveIsolationScope = sandboxFilesystemSpec.getIsolationScope(); + } + String effectiveFlushPrompt = memoryConfig.flushPrompt() != null ? memoryConfig.flushPrompt() @@ -1914,7 +1923,8 @@ public HarnessAgent build() { wsManager, memoryModel, effectiveFlushPrompt, - memoryConfig.flushTrigger())); + memoryConfig.flushTrigger(), + effectiveIsolationScope)); String effectiveConsolidationPrompt = memoryConfig.consolidationPrompt() != null @@ -1932,7 +1942,8 @@ public HarnessAgent build() { consolidator, memoryConfig.dailyFileRetentionDays(), memoryConfig.sessionRetentionDays(), - memoryConfig.consolidationMinGap())); + memoryConfig.consolidationMinGap(), + effectiveIsolationScope)); } CompactionMiddleware compactionHook = null; if (compactionConfig != null) { diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/RemoteFilesystemSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/RemoteFilesystemSpec.java index 1268d636b9..bbad372796 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/RemoteFilesystemSpec.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/RemoteFilesystemSpec.java @@ -57,6 +57,7 @@ *

  • {@code skills/} → segment {@code skills} *
  • {@code subagents/} → segment {@code subagents} *
  • {@code knowledge/} → segment {@code knowledge} + *
  • {@code plans/} → segment {@code plans} *
  • {@code agents//sessions/} → segment {@code sessions} *
  • {@code agents//tasks/} → segment {@code tasks} * @@ -154,6 +155,10 @@ public RemoteFilesystemSpec isolationScope(IsolationScope scope) { return this; } + public IsolationScope getIsolationScope() { + return isolationScope; + } + /** * Sets the workspace index for accelerating remote filesystem reads (ls/glob/exists/grep). * If not set, the remote filesystem falls back to full store scans. @@ -168,7 +173,8 @@ public RemoteFilesystemSpec workspaceIndex(WorkspaceIndex index) { *
      *
    • default backend: {@link LocalFilesystem} (no shell), per-user namespaced *
    • shared prefix routes ({@code memory/}, {@code skills/}, {@code subagents/}, - * {@code knowledge/}, {@code agents//sessions/}, {@code agents//tasks/}, plus + * {@code knowledge/}, {@code plans/}, {@code agents//sessions/}, + * {@code agents//tasks/}, plus * any {@code addSharedPrefix} extras): wrapped in an {@link OverlayFilesystem} where * the upper layer is the {@link RemoteFilesystem} (per-user, persisted in the * {@link BaseStore}) and the lower layer is a read-only {@link LocalFilesystem} @@ -214,6 +220,7 @@ public AbstractFilesystem toFilesystem( routes.put( "knowledge/", overlayRoute(workspace.resolve("knowledge"), "knowledge", effectiveAgentId)); + routes.put("plans/", overlayRoute(workspace.resolve("plans"), "plans", effectiveAgentId)); routes.put( "agents/" + effectiveAgentId + "/sessions/", overlayRoute( diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java index 3771d5c787..b1d62de362 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/HarnessSkillMiddleware.java @@ -57,10 +57,18 @@ *
    • Build a {@link SkillCatalog} of {@link HarnessSkillEntry} (with lazy resources and * resolved {@code filesRoot}). *
    • Install the catalog into the {@link SkillRuntime}, which (idempotently) registers the - * {@code load_skill_through_path} tool on the toolkit. + * {@code load_skill_through_path} tool on the agent's runtime toolkit. *
    • Render the {@code } prompt block and append it to the current system * prompt. * + * + *

      Toolkit note: the {@code toolkit} constructor parameter is accepted for API + * compatibility but is not used for runtime tool registration. Instead, + * {@link #onSystemPrompt} always installs into {@code agent.getToolkit()} so the tool is + * registered on the toolkit that the running agent actually uses for reasoning. This matters + * because {@link io.agentscope.harness.agent.HarnessAgent HarnessAgent} makes a deep copy of + * the toolkit when building the inner {@link io.agentscope.core.ReActAgent ReActAgent}, so the + * constructor-injected intermediate toolkit is not the same instance as the agent's live toolkit. */ @SuppressWarnings("deprecation") public class HarnessSkillMiddleware implements MiddlewareBase { @@ -103,7 +111,8 @@ public HarnessSkillMiddleware( * Full constructor. * * @param repositories compose-ordered list (low-to-high priority) - * @param toolkit toolkit to register {@code load_skill_through_path} on + * @param toolkit accepted for API compatibility; not used for runtime registration + * (see class-level note on toolkit copy semantics) * @param builderFilter skill filter passed at agent build time (may be {@code null}) * @param visibilityFilter optional per-request filter (canary/allow-list) * @param stager marketplace stager; {@code null} disables staging entirely @@ -142,15 +151,17 @@ public Mono onSystemPrompt(Agent agent, RuntimeContext ctx, String curre ctx = RuntimeContext.empty(); } + Toolkit agentToolkit = agent != null ? agent.getToolkit() : null; + Map merged = mergeRepositories(ctx); if (merged.isEmpty()) { - runtime.install(SkillCatalog.empty(), toolkit); + runtime.install(SkillCatalog.empty(), agentToolkit); return Mono.just(currentPrompt); } List visible = applyVisibility(merged.values(), ctx); if (visible.isEmpty()) { - runtime.install(SkillCatalog.empty(), toolkit); + runtime.install(SkillCatalog.empty(), agentToolkit); return Mono.just(currentPrompt); } @@ -176,7 +187,7 @@ public Mono onSystemPrompt(Agent agent, RuntimeContext ctx, String curre } SkillCatalog catalog = SkillCatalog.of(entries); - runtime.install(catalog, toolkit); + runtime.install(catalog, agentToolkit); SkillFilter effective = builderFilter.overlay(ctx != null ? ctx.get(SkillFilter.class) : null); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddleware.java index 68ead19fcb..a13022d52f 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddleware.java @@ -24,12 +24,14 @@ import io.agentscope.core.middleware.MiddlewareBase; import io.agentscope.core.model.Model; import io.agentscope.core.state.AgentState; +import io.agentscope.harness.agent.IsolationScope; import io.agentscope.harness.agent.memory.MemoryConfig; import io.agentscope.harness.agent.memory.MemoryFlushManager; import io.agentscope.harness.agent.workspace.WorkspaceManager; import java.time.Duration; import java.time.Instant; import java.util.List; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import org.slf4j.Logger; @@ -55,6 +57,15 @@ * *

      Message offload is independent of the flush trigger and runs on every call so the * session JSONL stays complete (needed for {@code SessionSearchTool} and resumption). + * + *

      The throttle window is tracked per isolation key, which matches the memory data + * isolation in use: + *

        + *
      • {@link IsolationScope#USER} (default) — one window per {@code userId}.
      • + *
      • {@link IsolationScope#SESSION} — one window per {@code sessionId}.
      • + *
      • {@link IsolationScope#AGENT} / {@link IsolationScope#GLOBAL} — one shared window for + * the whole agent instance (prevents concurrent flush races on shared memory files).
      • + *
      */ public class MemoryFlushMiddleware implements MiddlewareBase { @@ -64,15 +75,24 @@ public class MemoryFlushMiddleware implements MiddlewareBase { private final Model model; private final String flushPrompt; private final MemoryConfig.FlushTrigger flushTrigger; + private final IsolationScope isolationScope; - private final AtomicReference lastFlushAt = new AtomicReference<>(Instant.EPOCH); + /** + * Per-isolation-key flush timestamps. The key is derived from {@link #isolationScope} and the + * per-call {@link RuntimeContext} so the throttle window matches the memory data namespace: + * one window per user (USER scope), per session (SESSION scope), or a single shared window + * (AGENT / GLOBAL scope). + */ + private final ConcurrentHashMap> lastFlushAtByKey = + new ConcurrentHashMap<>(); public MemoryFlushMiddleware(WorkspaceManager workspaceManager, Model model) { this( workspaceManager, model, MemoryFlushManager.DEFAULT_FLUSH_PROMPT, - MemoryConfig.FlushTrigger.always()); + MemoryConfig.FlushTrigger.always(), + IsolationScope.USER); } public MemoryFlushMiddleware( @@ -80,12 +100,22 @@ public MemoryFlushMiddleware( Model model, String flushPrompt, MemoryConfig.FlushTrigger flushTrigger) { + this(workspaceManager, model, flushPrompt, flushTrigger, IsolationScope.USER); + } + + public MemoryFlushMiddleware( + WorkspaceManager workspaceManager, + Model model, + String flushPrompt, + MemoryConfig.FlushTrigger flushTrigger, + IsolationScope isolationScope) { this.workspaceManager = workspaceManager; this.model = model; this.flushPrompt = flushPrompt != null ? flushPrompt : MemoryFlushManager.DEFAULT_FLUSH_PROMPT; this.flushTrigger = flushTrigger != null ? flushTrigger : MemoryConfig.FlushTrigger.always(); + this.isolationScope = isolationScope != null ? isolationScope : IsolationScope.USER; } @Override @@ -114,7 +144,7 @@ private reactor.core.publisher.Mono doFlush(Agent agent, RuntimeContext rc MemoryFlushManager flushManager = new MemoryFlushManager(workspaceManager, model, flushPrompt); - boolean shouldFlush = shouldFlushNow(); + boolean shouldFlush = shouldFlushNow(rc); reactor.core.publisher.Mono flushMono; if (shouldFlush) { flushMono = @@ -155,10 +185,13 @@ private reactor.core.publisher.Mono doFlush(Agent agent, RuntimeContext rc * For {@link MemoryConfig.FlushMode#THROTTLED}, uses an {@link AtomicReference#compareAndSet} * race to ensure at most one caller within {@code minGap} wins the slot. * + *

      The throttle window is keyed by the isolation dimension that matches the memory data + * namespace (see {@link #timerKeyFor(RuntimeContext)}). + * *

      Package-private for unit testing of the trigger gate without standing up a full * {@code ReActAgent}. */ - boolean shouldFlushNow() { + boolean shouldFlushNow(RuntimeContext rc) { switch (flushTrigger.mode()) { case ALWAYS: return true; @@ -166,14 +199,44 @@ boolean shouldFlushNow() { return false; case THROTTLED: Instant now = Instant.now(); - Instant last = lastFlushAt.get(); + AtomicReference ref = lastFlushAtFor(rc); + Instant last = ref.get(); Duration minGap = flushTrigger.minGap(); if (Duration.between(last, now).compareTo(minGap) < 0) { return false; } - return lastFlushAt.compareAndSet(last, now); + return ref.compareAndSet(last, now); default: return true; } } + + private AtomicReference lastFlushAtFor(RuntimeContext rc) { + return lastFlushAtByKey.computeIfAbsent( + timerKeyFor(rc), k -> new AtomicReference<>(Instant.EPOCH)); + } + + /** + * Derives the timer map key from the configured {@link IsolationScope} and the per-call + * {@link RuntimeContext}, mirroring the memory data namespace: + *

        + *
      • {@link IsolationScope#USER} — {@code userId} (empty string for anonymous)
      • + *
      • {@link IsolationScope#SESSION} — {@code sessionId} (empty string when absent)
      • + *
      • {@link IsolationScope#AGENT} / {@link IsolationScope#GLOBAL} — constant {@code ""} + * so all callers share one throttle slot, serialising flushes on shared memory files
      • + *
      + */ + String timerKeyFor(RuntimeContext rc) { + return switch (isolationScope) { + case USER -> { + String uid = rc != null ? rc.getUserId() : null; + yield (uid != null && !uid.isBlank()) ? uid : ""; + } + case SESSION -> { + String sid = rc != null ? rc.getSessionId() : null; + yield (sid != null && !sid.isBlank()) ? sid : ""; + } + case AGENT, GLOBAL -> ""; + }; + } } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryMaintenanceMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryMaintenanceMiddleware.java index 776a42e87a..33a925a393 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryMaintenanceMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/MemoryMaintenanceMiddleware.java @@ -20,6 +20,7 @@ import io.agentscope.core.event.AgentEvent; import io.agentscope.core.middleware.AgentInput; import io.agentscope.core.middleware.MiddlewareBase; +import io.agentscope.harness.agent.IsolationScope; import io.agentscope.harness.agent.filesystem.AbstractFilesystem; import io.agentscope.harness.agent.filesystem.model.FileInfo; import io.agentscope.harness.agent.filesystem.model.GlobResult; @@ -29,6 +30,7 @@ import java.time.Duration; import java.time.Instant; import java.time.LocalDate; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import org.slf4j.Logger; @@ -50,6 +52,15 @@ * consolidator is configured.
    • *
    • Prune session log files older than {@code sessionRetentionDays}.
    • * + * + *

      The throttle window is tracked per isolation key, which matches the memory data + * isolation in use: + *

        + *
      • {@link IsolationScope#USER} (default) — one window per {@code userId}.
      • + *
      • {@link IsolationScope#SESSION} — one window per {@code sessionId}.
      • + *
      • {@link IsolationScope#AGENT} / {@link IsolationScope#GLOBAL} — one shared window for + * the whole agent instance (prevents concurrent maintenance races on shared memory files).
      • + *
      */ public class MemoryMaintenanceMiddleware implements MiddlewareBase { @@ -63,8 +74,15 @@ public class MemoryMaintenanceMiddleware implements MiddlewareBase { private final int dailyFileRetentionDays; private final int sessionRetentionDays; private final Duration minGap; + private final IsolationScope isolationScope; - private final AtomicReference lastRunAt = new AtomicReference<>(Instant.EPOCH); + /** + * Per-isolation-key maintenance timestamps. The key is derived from {@link #isolationScope} + * and the per-call {@link RuntimeContext} so the throttle window matches the memory data + * namespace (see {@link MemoryFlushMiddleware} for the identical pattern). + */ + private final ConcurrentHashMap> lastRunAtByKey = + new ConcurrentHashMap<>(); public MemoryMaintenanceMiddleware( WorkspaceManager workspaceManager, @@ -72,11 +90,28 @@ public MemoryMaintenanceMiddleware( int dailyFileRetentionDays, int sessionRetentionDays, Duration minGap) { + this( + workspaceManager, + consolidator, + dailyFileRetentionDays, + sessionRetentionDays, + minGap, + IsolationScope.USER); + } + + public MemoryMaintenanceMiddleware( + WorkspaceManager workspaceManager, + MemoryConsolidator consolidator, + int dailyFileRetentionDays, + int sessionRetentionDays, + Duration minGap, + IsolationScope isolationScope) { this.workspaceManager = workspaceManager; this.consolidator = consolidator; this.dailyFileRetentionDays = dailyFileRetentionDays; this.sessionRetentionDays = sessionRetentionDays; this.minGap = minGap != null ? minGap : DEFAULT_MIN_GAP; + this.isolationScope = isolationScope != null ? isolationScope : IsolationScope.USER; } public MemoryMaintenanceMiddleware( @@ -96,11 +131,12 @@ public Flux onAgent( private void maybeRunMaintenance(RuntimeContext rc) { Instant now = Instant.now(); - Instant last = lastRunAt.get(); + AtomicReference ref = lastRunAtFor(rc); + Instant last = ref.get(); if (Duration.between(last, now).compareTo(minGap) < 0) { return; } - if (!lastRunAt.compareAndSet(last, now)) { + if (!ref.compareAndSet(last, now)) { return; } try { @@ -110,6 +146,30 @@ private void maybeRunMaintenance(RuntimeContext rc) { } } + private AtomicReference lastRunAtFor(RuntimeContext rc) { + return lastRunAtByKey.computeIfAbsent( + timerKeyFor(rc), k -> new AtomicReference<>(Instant.EPOCH)); + } + + /** + * Derives the timer map key from the configured {@link IsolationScope} and the per-call + * {@link RuntimeContext}, mirroring the memory data namespace. See + * {@link MemoryFlushMiddleware#timerKeyFor(RuntimeContext)} for the same logic. + */ + String timerKeyFor(RuntimeContext rc) { + return switch (isolationScope) { + case USER -> { + String uid = rc != null ? rc.getUserId() : null; + yield (uid != null && !uid.isBlank()) ? uid : ""; + } + case SESSION -> { + String sid = rc != null ? rc.getSessionId() : null; + yield (sid != null && !sid.isBlank()) ? sid : ""; + } + case AGENT, GLOBAL -> ""; + }; + } + private void runMaintenance(RuntimeContext rc) { log.debug("Running memory maintenance..."); expireDailyFiles(rc); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/plan/PlanModeManager.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/plan/PlanModeManager.java index 900d8d8b71..de194d2664 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/plan/PlanModeManager.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/plan/PlanModeManager.java @@ -32,7 +32,9 @@ * *

      The plan markdown file is written exclusively through {@link WorkspaceManager}, never via * {@code java.nio.file.Files}, so it lands on whatever backend (local, sandbox, remote) the agent's - * filesystem is configured with. + * filesystem is configured with. The logical path stays {@code /PLAN.md} for every + * isolation scope; per-scope isolation is applied transparently by the filesystem (store namespace + * for remote, snapshot key for sandbox) rather than by encoding it into the path. */ public final class PlanModeManager { diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddlewareTriggerTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddlewareTriggerTest.java index c1214b7764..e4ad880ff0 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddlewareTriggerTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/middleware/MemoryFlushMiddlewareTriggerTest.java @@ -15,9 +15,12 @@ */ package io.agentscope.harness.agent.middleware; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.IsolationScope; import io.agentscope.harness.agent.memory.MemoryConfig; import io.agentscope.harness.agent.memory.MemoryFlushManager; import java.time.Duration; @@ -30,17 +33,34 @@ */ class MemoryFlushMiddlewareTriggerTest { + private static final RuntimeContext RC_ANON = RuntimeContext.empty(); + private static final RuntimeContext RC_USER_A = + RuntimeContext.builder().userId("userA").build(); + private static final RuntimeContext RC_USER_B = + RuntimeContext.builder().userId("userB").build(); + private static final RuntimeContext RC_SESSION_1 = + RuntimeContext.builder().userId("userA").sessionId("session1").build(); + private static final RuntimeContext RC_SESSION_2 = + RuntimeContext.builder().userId("userA").sessionId("session2").build(); + + /** Creates a USER-scope (default) middleware for trigger-gate tests. */ private static MemoryFlushMiddleware make(MemoryConfig.FlushTrigger trigger) { - // workspaceManager + model are only consumed inside doFlush, which we don't call here + return make(trigger, IsolationScope.USER); + } + + /** workspaceManager + model are only consumed inside doFlush, which we don't call here. */ + private static MemoryFlushMiddleware make( + MemoryConfig.FlushTrigger trigger, IsolationScope scope) { return new MemoryFlushMiddleware( - null, null, MemoryFlushManager.DEFAULT_FLUSH_PROMPT, trigger); + null, null, MemoryFlushManager.DEFAULT_FLUSH_PROMPT, trigger, scope); } @Test void alwaysMode_returnsTrueOnEveryCall() { MemoryFlushMiddleware mw = make(MemoryConfig.FlushTrigger.always()); for (int i = 0; i < 5; i++) { - assertTrue(mw.shouldFlushNow(), "ALWAYS should always return true (i=" + i + ")"); + assertTrue( + mw.shouldFlushNow(RC_ANON), "ALWAYS should always return true (i=" + i + ")"); } } @@ -48,7 +68,8 @@ void alwaysMode_returnsTrueOnEveryCall() { void neverMode_returnsFalseOnEveryCall() { MemoryFlushMiddleware mw = make(MemoryConfig.FlushTrigger.never()); for (int i = 0; i < 5; i++) { - assertFalse(mw.shouldFlushNow(), "NEVER should always return false (i=" + i + ")"); + assertFalse( + mw.shouldFlushNow(RC_ANON), "NEVER should always return false (i=" + i + ")"); } } @@ -57,9 +78,9 @@ void throttledMode_firstCallWinsThenBackOff() { // 1-hour gap — way larger than the test runtime, so only the first call should pass. MemoryFlushMiddleware mw = make(MemoryConfig.FlushTrigger.throttled(Duration.ofHours(1))); - assertTrue(mw.shouldFlushNow(), "first call must win the slot"); - assertFalse(mw.shouldFlushNow(), "second call within the window must be throttled"); - assertFalse(mw.shouldFlushNow(), "third call within the window must be throttled"); + assertTrue(mw.shouldFlushNow(RC_ANON), "first call must win the slot"); + assertFalse(mw.shouldFlushNow(RC_ANON), "second call within the window must be throttled"); + assertFalse(mw.shouldFlushNow(RC_ANON), "third call within the window must be throttled"); } @Test @@ -67,14 +88,15 @@ void throttledMode_smallGapEventuallyReleases() throws InterruptedException { Duration gap = Duration.ofMillis(50); MemoryFlushMiddleware mw = make(MemoryConfig.FlushTrigger.throttled(gap)); - assertTrue(mw.shouldFlushNow(), "first call wins"); - assertFalse(mw.shouldFlushNow(), "immediate retry is throttled"); + assertTrue(mw.shouldFlushNow(RC_ANON), "first call wins"); + assertFalse(mw.shouldFlushNow(RC_ANON), "immediate retry is throttled"); // Sleep just over the gap so the next call can re-acquire. Thread.sleep(gap.toMillis() * 3); - assertTrue(mw.shouldFlushNow(), "after gap, slot is free again"); - assertFalse(mw.shouldFlushNow(), "immediate retry after the new winner is throttled"); + assertTrue(mw.shouldFlushNow(RC_ANON), "after gap, slot is free again"); + assertFalse( + mw.shouldFlushNow(RC_ANON), "immediate retry after the new winner is throttled"); } @Test @@ -83,7 +105,107 @@ void throttledMode_zeroGapNormalisesToAlways() { // behaves accordingly even when callers pass the zero-Duration form. MemoryFlushMiddleware mw = make(MemoryConfig.FlushTrigger.throttled(Duration.ZERO)); for (int i = 0; i < 3; i++) { - assertTrue(mw.shouldFlushNow(), "zero-gap throttling must behave like ALWAYS"); + assertTrue(mw.shouldFlushNow(RC_ANON), "zero-gap throttling must behave like ALWAYS"); } } + + @Test + void throttledMode_perUserIsolation_userBNotBlockedByUserA() { + // 1-hour gap. User A wins the slot. User B must still get their own independent slot + // on the same shared middleware instance (shared agent multi-tenant scenario). + MemoryFlushMiddleware mw = make(MemoryConfig.FlushTrigger.throttled(Duration.ofHours(1))); + + assertTrue(mw.shouldFlushNow(RC_USER_A), "user A wins their slot"); + assertFalse(mw.shouldFlushNow(RC_USER_A), "user A is now throttled"); + + assertTrue( + mw.shouldFlushNow(RC_USER_B), + "user B must win their own independent slot even though user A already flushed"); + assertFalse(mw.shouldFlushNow(RC_USER_B), "user B is now throttled in their own window"); + } + + @Test + void throttledMode_anonymousCallersShareOneSlot() { + // Callers without a userId are treated as a single anonymous tenant and share one slot. + MemoryFlushMiddleware mw = make(MemoryConfig.FlushTrigger.throttled(Duration.ofHours(1))); + + assertTrue(mw.shouldFlushNow(RC_ANON), "first anonymous call wins"); + assertFalse(mw.shouldFlushNow(RC_ANON), "second anonymous call is throttled"); + assertFalse(mw.shouldFlushNow(null), "null rc also maps to anonymous slot"); + } + + // ── IsolationScope.SESSION ──────────────────────────────────────────────── + + @Test + void sessionScope_timerKeyUsesSessionId() { + MemoryFlushMiddleware mw = make(MemoryConfig.FlushTrigger.always(), IsolationScope.SESSION); + assertEquals("session1", mw.timerKeyFor(RC_SESSION_1)); + assertEquals("session2", mw.timerKeyFor(RC_SESSION_2)); + assertEquals("", mw.timerKeyFor(RC_ANON), "no sessionId → empty key"); + } + + @Test + void sessionScope_sameUserDifferentSessionsAreIndependent() { + // SESSION scope: same userId but different sessionIds → each session has its own throttle. + MemoryFlushMiddleware mw = + make( + MemoryConfig.FlushTrigger.throttled(Duration.ofHours(1)), + IsolationScope.SESSION); + + assertTrue(mw.shouldFlushNow(RC_SESSION_1), "session1 wins its slot"); + assertFalse(mw.shouldFlushNow(RC_SESSION_1), "session1 is now throttled"); + + assertTrue( + mw.shouldFlushNow(RC_SESSION_2), + "session2 must win its own independent slot even though session1 already flushed"); + assertFalse(mw.shouldFlushNow(RC_SESSION_2), "session2 is now throttled"); + } + + // ── IsolationScope.AGENT / GLOBAL ──────────────────────────────────────── + + @Test + void agentScope_timerKeyIsConstant() { + MemoryFlushMiddleware mw = make(MemoryConfig.FlushTrigger.always(), IsolationScope.AGENT); + assertEquals("", mw.timerKeyFor(RC_USER_A), "AGENT scope uses empty key for all callers"); + assertEquals("", mw.timerKeyFor(RC_USER_B)); + assertEquals("", mw.timerKeyFor(RC_ANON)); + } + + @Test + void agentScope_allCallersShareOneThrottleSlot() { + // AGENT scope: memory is shared across all users → all callers must share one throttle + // window to avoid concurrent maintenance races on shared MEMORY.md. + MemoryFlushMiddleware mw = + make( + MemoryConfig.FlushTrigger.throttled(Duration.ofHours(1)), + IsolationScope.AGENT); + + assertTrue(mw.shouldFlushNow(RC_USER_A), "userA wins the shared slot"); + assertFalse(mw.shouldFlushNow(RC_USER_A), "userA is throttled"); + assertFalse( + mw.shouldFlushNow(RC_USER_B), + "userB must also be blocked because they share the same memory namespace"); + } + + @Test + void globalScope_allCallersShareOneThrottleSlot() { + MemoryFlushMiddleware mw = + make( + MemoryConfig.FlushTrigger.throttled(Duration.ofHours(1)), + IsolationScope.GLOBAL); + + assertTrue(mw.shouldFlushNow(RC_USER_A), "first caller wins the global slot"); + assertFalse(mw.shouldFlushNow(RC_USER_B), "second caller blocked by shared global slot"); + } + + // ── timerKeyFor edge cases ──────────────────────────────────────────────── + + @Test + void userScope_timerKeyUsesUserId() { + MemoryFlushMiddleware mw = make(MemoryConfig.FlushTrigger.always(), IsolationScope.USER); + assertEquals("userA", mw.timerKeyFor(RC_USER_A)); + assertEquals("userB", mw.timerKeyFor(RC_USER_B)); + assertEquals("", mw.timerKeyFor(RC_ANON), "no userId → empty key"); + assertEquals("", mw.timerKeyFor(null), "null rc → empty key"); + } } From 9a6980d37f7fcb5e3b4765b845e213617ba6be7a Mon Sep 17 00:00:00 2001 From: Chickenlj Date: Wed, 10 Jun 2026 14:18:56 +0800 Subject: [PATCH 02/11] feat(event): add CustomEvent and HintBlockEvent types with metadata support --- .../io/agentscope/core/event/AgentEvent.java | 24 +++++- .../agentscope/core/event/AgentEventType.java | 5 +- .../io/agentscope/core/event/CustomEvent.java | 82 ++++++++++++++++++ .../agentscope/core/event/HintBlockEvent.java | 85 +++++++++++++++++++ .../io/agentscope/core/message/HintBlock.java | 20 ++++- 5 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 agentscope-core/src/main/java/io/agentscope/core/event/CustomEvent.java create mode 100644 agentscope-core/src/main/java/io/agentscope/core/event/HintBlockEvent.java diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java index ef152bc238..3aad2d7130 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEvent.java @@ -20,6 +20,8 @@ import com.fasterxml.jackson.annotation.JsonSubTypes; import com.fasterxml.jackson.annotation.JsonTypeInfo; import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.Map; import java.util.UUID; /** @@ -63,7 +65,9 @@ value = ExternalExecutionResultEvent.class, name = "EXTERNAL_EXECUTION_RESULT"), @JsonSubTypes.Type(value = RequestStopEvent.class, name = "REQUEST_STOP"), - @JsonSubTypes.Type(value = SubagentExposedEvent.class, name = "SUBAGENT_EXPOSED") + @JsonSubTypes.Type(value = SubagentExposedEvent.class, name = "SUBAGENT_EXPOSED"), + @JsonSubTypes.Type(value = HintBlockEvent.class, name = "HINT_BLOCK"), + @JsonSubTypes.Type(value = CustomEvent.class, name = "CUSTOM") }) public abstract class AgentEvent { @@ -71,6 +75,9 @@ public abstract class AgentEvent { private final String createdAt; private String source; + @JsonInclude(JsonInclude.Include.NON_EMPTY) + private Map metadata; + protected AgentEvent() { this.id = UUID.randomUUID().toString().replace("-", ""); this.createdAt = Instant.now().toString(); @@ -109,6 +116,21 @@ public AgentEvent withSource(String source) { return this; } + /** + * Returns optional metadata attached to this event. May be {@code null} or empty. + */ + public Map getMetadata() { + return metadata; + } + + /** + * Attaches arbitrary key-value metadata to this event and returns it for chaining. + */ + public AgentEvent withMetadata(Map metadata) { + this.metadata = metadata != null ? new LinkedHashMap<>(metadata) : null; + return this; + } + @Override public String toString() { return getClass().getSimpleName() + "{id='" + id + "', type=" + getType() + '}'; diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java index 0149d18af4..ec89997586 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/AgentEventType.java @@ -83,7 +83,10 @@ public enum AgentEventType { REQUEST_STOP("REQUEST_STOP"), @JsonAlias({"THREAD_EXPOSED"}) - SUBAGENT_EXPOSED("SUBAGENT_EXPOSED"); + SUBAGENT_EXPOSED("SUBAGENT_EXPOSED"), + + HINT_BLOCK("HINT_BLOCK"), + CUSTOM("CUSTOM"); private final String value; diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/CustomEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/CustomEvent.java new file mode 100644 index 0000000000..1d8fef544f --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/CustomEvent.java @@ -0,0 +1,82 @@ +/* + * Copyright 2024-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 + * + * 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 io.agentscope.core.event; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.Map; + +/** + * Generic extensible event for signals that don't fit a specific {@link AgentEvent} subtype. + * + *

      Used by service-layer middleware to notify front-end subscribers about state changes (task + * progress, team membership, permission updates, etc.) without polluting the core agent event enum + * with application-specific types. + * + *

      Front-end implementations should handle unknown {@link #getName()} values gracefully — skip + * with no error. + * + *

      Well-known {@code name} values: + *

        + *
      • {@code "state_updated"} — agent state (tasks / permission) changed during a tool call
      • + *
      • {@code "team_updated"} — team membership changed (member added / team created or + * dissolved)
      • + *
      + */ +public class CustomEvent extends AgentEvent { + + private final String name; + private final Map value; + + @JsonCreator + public CustomEvent( + @JsonProperty("id") String id, + @JsonProperty("createdAt") String createdAt, + @JsonProperty("name") String name, + @JsonProperty("value") Map value) { + super(id, createdAt); + this.name = name; + this.value = value != null ? value : Map.of(); + } + + public CustomEvent(String name, Map value) { + this.name = name; + this.value = value != null ? value : Map.of(); + } + + public CustomEvent(String name) { + this(name, Map.of()); + } + + @Override + public AgentEventType getType() { + return AgentEventType.CUSTOM; + } + + /** + * Returns the kind of notification. See class javadoc for well-known values. + */ + public String getName() { + return name; + } + + /** + * Returns the arbitrary JSON-serializable payload whose schema depends on {@link #getName()}. + */ + public Map getValue() { + return value; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/HintBlockEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/HintBlockEvent.java new file mode 100644 index 0000000000..932cf7f3a7 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/event/HintBlockEvent.java @@ -0,0 +1,85 @@ +/* + * Copyright 2024-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 + * + * 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 io.agentscope.core.event; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonProperty; + +/** + * One-shot hint block event. + * + *

      Unlike text/thinking blocks, hint blocks are not streamed — the full content is available at + * creation time (team messages, background tool results, user interruptions, etc.). A single event + * carries the complete hint. + */ +public class HintBlockEvent extends AgentEvent { + + private final String replyId; + private final String blockId; + private final String hintSource; + private final String hint; + + @JsonCreator + public HintBlockEvent( + @JsonProperty("id") String id, + @JsonProperty("createdAt") String createdAt, + @JsonProperty("replyId") String replyId, + @JsonProperty("blockId") String blockId, + @JsonProperty("hintSource") String hintSource, + @JsonProperty("hint") String hint) { + super(id, createdAt); + this.replyId = replyId; + this.blockId = blockId; + this.hintSource = hintSource; + this.hint = hint; + } + + public HintBlockEvent(String replyId, String blockId, String hintSource, String hint) { + this.replyId = replyId; + this.blockId = blockId; + this.hintSource = hintSource; + this.hint = hint; + } + + @Override + public AgentEventType getType() { + return AgentEventType.HINT_BLOCK; + } + + public String getReplyId() { + return replyId; + } + + public String getBlockId() { + return blockId; + } + + /** + * Returns the sender or origin of this hint. For team messages this is the sender's display + * name (e.g. {@code "alice"}); for system notifications it may be {@code "system"} or + * {@code null}. + * + *

      Named {@code hintSource} to avoid collision with {@link AgentEvent#getSource()} which + * carries the subagent forwarding path. + */ + public String getHintSource() { + return hintSource; + } + + public String getHint() { + return hint; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/message/HintBlock.java b/agentscope-core/src/main/java/io/agentscope/core/message/HintBlock.java index 337e4339b9..c4d0d9e0be 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/message/HintBlock.java +++ b/agentscope-core/src/main/java/io/agentscope/core/message/HintBlock.java @@ -29,11 +29,20 @@ public final class HintBlock extends ContentBlock { private final String id; private final String hint; + private final String source; @JsonCreator - public HintBlock(@JsonProperty("id") String id, @JsonProperty("hint") String hint) { + public HintBlock( + @JsonProperty("id") String id, + @JsonProperty("hint") String hint, + @JsonProperty("source") String source) { this.id = id; this.hint = hint; + this.source = source; + } + + public HintBlock(String id, String hint) { + this(id, hint, null); } /** @@ -53,4 +62,13 @@ public String getId() { public String getHint() { return hint; } + + /** + * Returns the sender or origin of this hint. For team messages this is the sender's display + * name (e.g. {@code "alice"}); for system notifications it may be {@code "system"} or + * {@code null}. + */ + public String getSource() { + return source; + } } From e63f25444173d0515f1fe9ede91b1faa9bd72c2c Mon Sep 17 00:00:00 2001 From: Chickenlj Date: Wed, 10 Jun 2026 15:40:01 +0800 Subject: [PATCH 03/11] fix(event): add toolCallName to all tool event types and fix ToolResultEvictionMiddleware timing ToolCall/ToolResult delta and end events now carry toolCallName so consumers don't need to cache the start event's name mapping. ToolResultEvictionMiddleware moved from onActing (where state hadn't been written yet, making it a no-op) to onReasoning where tool results are already in state. --- .../java/io/agentscope/core/ReActAgent.java | 41 ++++++++++++++----- .../core/event/ToolCallDeltaEvent.java | 11 ++++- .../core/event/ToolCallEndEvent.java | 12 +++++- .../core/event/ToolResultDataDeltaEvent.java | 11 ++++- .../core/event/ToolResultEndEvent.java | 11 ++++- .../core/event/ToolResultTextDeltaEvent.java | 11 ++++- .../tracing/OtelTracingMiddlewareTest.java | 2 +- .../agent/middleware/PlanModeMiddleware.java | 10 ++++- .../ToolResultEvictionMiddleware.java | 19 ++++----- 9 files changed, 98 insertions(+), 30 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index 6fbb9989a4..e2af9a631e 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -2022,7 +2022,7 @@ private Flux modelCallStream( String replyId = UUID.randomUUID().toString().replace("-", ""); AtomicBoolean textStarted = new AtomicBoolean(false); AtomicBoolean thinkingStarted = new AtomicBoolean(false); - Set startedToolCalls = ConcurrentHashMap.newKeySet(); + Map startedToolCalls = new ConcurrentHashMap<>(); Flux modelEvents = mci.model().stream(mci.messages(), mci.tools(), mci.options()) @@ -2049,7 +2049,7 @@ private Flux modelCallStream( thinkingStarted, withToolEvents ? startedToolCalls - : ConcurrentHashMap.newKeySet(), + : new ConcurrentHashMap<>(), events); } return Flux.fromIterable(events); @@ -2065,8 +2065,10 @@ private Flux modelCallStream( if (thinkingStarted.get()) { events.add(new ThinkingBlockEndEvent(replyId, "thinking")); } - for (String toolId : startedToolCalls) { - events.add(new ToolCallEndEvent(replyId, toolId)); + for (Map.Entry tc : startedToolCalls.entrySet()) { + events.add( + new ToolCallEndEvent( + replyId, tc.getKey(), tc.getValue())); } events.add(new ModelCallEndEvent(replyId, context.getChatUsage())); return Flux.fromIterable(events); @@ -2081,7 +2083,7 @@ private void emitBlockEvents( ReasoningContext context, AtomicBoolean textStarted, AtomicBoolean thinkingStarted, - Set startedToolCalls, + Map startedToolCalls, List events) { if (block instanceof TextBlock tb) { @@ -2100,8 +2102,8 @@ private void emitBlockEvents( } } else if (block instanceof ToolUseBlock tub) { String toolId = resolveToolCallId(tub, context); - if (toolId != null && startedToolCalls.add(toolId)) { - String toolName = tub.getName(); + String toolName = tub.getName(); + if (toolId != null && startedToolCalls.putIfAbsent(toolId, toolName) == null) { if (toolName != null && !toolName.startsWith("__")) { events.add(new ToolCallStartEvent(replyId, toolId, toolName)); } @@ -2109,7 +2111,10 @@ private void emitBlockEvents( if (tub.getContent() != null && !tub.getContent().isEmpty()) { events.add( new ToolCallDeltaEvent( - replyId, toolId != null ? toolId : "", tub.getContent())); + replyId, + toolId != null ? toolId : "", + toolName, + tub.getContent())); } } } @@ -2353,10 +2358,12 @@ private Flux runToolBatch( new ToolResultTextDeltaEvent( replyId, use.getId(), + use.getName(), "Permission denied by user"), new ToolResultEndEvent( replyId, use.getId(), + use.getName(), ToolResultState.DENIED)); }); @@ -2403,6 +2410,8 @@ private Flux runToolBatch( replyId, toolUse .getId(), + toolUse + .getName(), tb .getText())); } else { @@ -2411,6 +2420,8 @@ private Flux runToolBatch( replyId, toolUse .getId(), + toolUse + .getName(), block)); } } @@ -2452,6 +2463,8 @@ private Flux runToolBatch( replyId, entry.getKey() .getId(), + entry.getKey() + .getName(), state)); } sink.complete(); @@ -2560,6 +2573,7 @@ private void emitToolResultDelta( Map.Entry entry, Set chunkedToolIds) { String toolId = entry.getKey().getId(); + String toolName = entry.getKey().getName(); if (chunkedToolIds.contains(toolId)) { return; } @@ -2569,9 +2583,10 @@ private void emitToolResultDelta( } for (ContentBlock block : output) { if (block instanceof TextBlock tb) { - sink.next(new ToolResultTextDeltaEvent(replyId, toolId, tb.getText())); + sink.next( + new ToolResultTextDeltaEvent(replyId, toolId, toolName, tb.getText())); } else { - sink.next(new ToolResultDataDeltaEvent(replyId, toolId, block)); + sink.next(new ToolResultDataDeltaEvent(replyId, toolId, toolName, block)); } } } @@ -3254,7 +3269,11 @@ public AgentState getAgentState(RuntimeContext ctx) { /** * Returns the {@link AgentState} for the given {@code (userId, sessionId)} slot, loading it * from the configured {@link AgentStateStore} on first access and caching it for subsequent - * calls. + * calls within this JVM. + * + *

      Note: in distributed deployments the authoritative reload happens at call start inside + * {@code activateSlotForContext}. This method returns the locally cached instance (suitable + * for the "get → mutate → save" pattern used by admin APIs and tests). */ public AgentState getAgentState(String userId, String sessionId) { String slot = slotKey(userId, sessionId); diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallDeltaEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallDeltaEvent.java index 060a52f1e1..2826c21975 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallDeltaEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallDeltaEvent.java @@ -25,6 +25,7 @@ public class ToolCallDeltaEvent extends AgentEvent { private final String replyId; private final String toolCallId; + private final String toolCallName; private final String delta; @JsonCreator @@ -33,16 +34,20 @@ public ToolCallDeltaEvent( @JsonProperty("createdAt") String createdAt, @JsonProperty("replyId") String replyId, @JsonProperty("toolCallId") String toolCallId, + @JsonProperty("toolCallName") String toolCallName, @JsonProperty("delta") String delta) { super(id, createdAt); this.replyId = replyId; this.toolCallId = toolCallId; + this.toolCallName = toolCallName; this.delta = delta; } - public ToolCallDeltaEvent(String replyId, String toolCallId, String delta) { + public ToolCallDeltaEvent( + String replyId, String toolCallId, String toolCallName, String delta) { this.replyId = replyId; this.toolCallId = toolCallId; + this.toolCallName = toolCallName; this.delta = delta; } @@ -59,6 +64,10 @@ public String getToolCallId() { return toolCallId; } + public String getToolCallName() { + return toolCallName; + } + public String getDelta() { return delta; } diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallEndEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallEndEvent.java index fb498aef29..69edd24f80 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallEndEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ToolCallEndEvent.java @@ -22,21 +22,25 @@ public class ToolCallEndEvent extends AgentEvent { private final String replyId; private final String toolCallId; + private final String toolCallName; @JsonCreator public ToolCallEndEvent( @JsonProperty("id") String id, @JsonProperty("createdAt") String createdAt, @JsonProperty("replyId") String replyId, - @JsonProperty("toolCallId") String toolCallId) { + @JsonProperty("toolCallId") String toolCallId, + @JsonProperty("toolCallName") String toolCallName) { super(id, createdAt); this.replyId = replyId; this.toolCallId = toolCallId; + this.toolCallName = toolCallName; } - public ToolCallEndEvent(String replyId, String toolCallId) { + public ToolCallEndEvent(String replyId, String toolCallId, String toolCallName) { this.replyId = replyId; this.toolCallId = toolCallId; + this.toolCallName = toolCallName; } @Override @@ -51,4 +55,8 @@ public String getReplyId() { public String getToolCallId() { return toolCallId; } + + public String getToolCallName() { + return toolCallName; + } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultDataDeltaEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultDataDeltaEvent.java index 3e10784f08..3b6c03efdc 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultDataDeltaEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultDataDeltaEvent.java @@ -23,6 +23,7 @@ public class ToolResultDataDeltaEvent extends AgentEvent { private final String replyId; private final String toolCallId; + private final String toolCallName; private final ContentBlock data; @JsonCreator @@ -31,16 +32,20 @@ public ToolResultDataDeltaEvent( @JsonProperty("createdAt") String createdAt, @JsonProperty("replyId") String replyId, @JsonProperty("toolCallId") String toolCallId, + @JsonProperty("toolCallName") String toolCallName, @JsonProperty("data") ContentBlock data) { super(id, createdAt); this.replyId = replyId; this.toolCallId = toolCallId; + this.toolCallName = toolCallName; this.data = data; } - public ToolResultDataDeltaEvent(String replyId, String toolCallId, ContentBlock data) { + public ToolResultDataDeltaEvent( + String replyId, String toolCallId, String toolCallName, ContentBlock data) { this.replyId = replyId; this.toolCallId = toolCallId; + this.toolCallName = toolCallName; this.data = data; } @@ -57,6 +62,10 @@ public String getToolCallId() { return toolCallId; } + public String getToolCallName() { + return toolCallName; + } + public ContentBlock getData() { return data; } diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultEndEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultEndEvent.java index de8d27aeae..f9321f56e3 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultEndEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultEndEvent.java @@ -23,6 +23,7 @@ public class ToolResultEndEvent extends AgentEvent { private final String replyId; private final String toolCallId; + private final String toolCallName; private final ToolResultState state; @JsonCreator @@ -31,16 +32,20 @@ public ToolResultEndEvent( @JsonProperty("createdAt") String createdAt, @JsonProperty("replyId") String replyId, @JsonProperty("toolCallId") String toolCallId, + @JsonProperty("toolCallName") String toolCallName, @JsonProperty("state") ToolResultState state) { super(id, createdAt); this.replyId = replyId; this.toolCallId = toolCallId; + this.toolCallName = toolCallName; this.state = state; } - public ToolResultEndEvent(String replyId, String toolCallId, ToolResultState state) { + public ToolResultEndEvent( + String replyId, String toolCallId, String toolCallName, ToolResultState state) { this.replyId = replyId; this.toolCallId = toolCallId; + this.toolCallName = toolCallName; this.state = state; } @@ -57,6 +62,10 @@ public String getToolCallId() { return toolCallId; } + public String getToolCallName() { + return toolCallName; + } + public ToolResultState getState() { return state; } diff --git a/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultTextDeltaEvent.java b/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultTextDeltaEvent.java index 41a9daf25f..f9b464b0d7 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultTextDeltaEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/event/ToolResultTextDeltaEvent.java @@ -22,6 +22,7 @@ public class ToolResultTextDeltaEvent extends AgentEvent { private final String replyId; private final String toolCallId; + private final String toolCallName; private final String delta; @JsonCreator @@ -30,16 +31,20 @@ public ToolResultTextDeltaEvent( @JsonProperty("createdAt") String createdAt, @JsonProperty("replyId") String replyId, @JsonProperty("toolCallId") String toolCallId, + @JsonProperty("toolCallName") String toolCallName, @JsonProperty("delta") String delta) { super(id, createdAt); this.replyId = replyId; this.toolCallId = toolCallId; + this.toolCallName = toolCallName; this.delta = delta; } - public ToolResultTextDeltaEvent(String replyId, String toolCallId, String delta) { + public ToolResultTextDeltaEvent( + String replyId, String toolCallId, String toolCallName, String delta) { this.replyId = replyId; this.toolCallId = toolCallId; + this.toolCallName = toolCallName; this.delta = delta; } @@ -56,6 +61,10 @@ public String getToolCallId() { return toolCallId; } + public String getToolCallName() { + return toolCallName; + } + public String getDelta() { return delta; } diff --git a/agentscope-core/src/test/java/io/agentscope/core/tracing/OtelTracingMiddlewareTest.java b/agentscope-core/src/test/java/io/agentscope/core/tracing/OtelTracingMiddlewareTest.java index cc09561bf7..1e8a0ee1fc 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/tracing/OtelTracingMiddlewareTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/tracing/OtelTracingMiddlewareTest.java @@ -183,7 +183,7 @@ void onActing_createsExecuteToolSpan() { .build(); ToolResultEndEvent tre = - new ToolResultEndEvent("reply-1", "call-1", ToolResultState.SUCCESS); + new ToolResultEndEvent("reply-1", "call-1", "testTool", ToolResultState.SUCCESS); ActingInput input = new ActingInput(List.of(toolCall)); Flux result = middleware.onActing(agent, null, input, in -> Flux.just(tre)); result.collectList().block(); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/PlanModeMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/PlanModeMiddleware.java index c8ce22ea1e..56cf3f95b2 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/PlanModeMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/PlanModeMiddleware.java @@ -213,10 +213,16 @@ public Flux onActing( replyId, call.getId(), call.getName())); events.add( new ToolResultTextDeltaEvent( - replyId, call.getId(), DENY_MESSAGE)); + replyId, + call.getId(), + call.getName(), + DENY_MESSAGE)); events.add( new ToolResultEndEvent( - replyId, call.getId(), ToolResultState.DENIED)); + replyId, + call.getId(), + call.getName(), + ToolResultState.DENIED)); } return Flux.fromIterable(events); }); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/ToolResultEvictionMiddleware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/ToolResultEvictionMiddleware.java index 1ae8d8914c..32bc8a5a69 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/ToolResultEvictionMiddleware.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/middleware/ToolResultEvictionMiddleware.java @@ -23,8 +23,8 @@ import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolResultBlock; -import io.agentscope.core.middleware.ActingInput; import io.agentscope.core.middleware.MiddlewareBase; +import io.agentscope.core.middleware.ReasoningInput; import io.agentscope.core.state.AgentState; import io.agentscope.harness.agent.filesystem.AbstractFilesystem; import io.agentscope.harness.agent.filesystem.model.WriteResult; @@ -69,25 +69,24 @@ public ToolResultEvictionMiddleware( } @Override - public Flux onActing( + public Flux onReasoning( Agent agent, RuntimeContext ctx, - ActingInput input, - Function> next) { + ReasoningInput input, + Function> next) { final RuntimeContext rc = ctx != null ? ctx : RuntimeContext.empty(); - AgentState state = RuntimeContext.resolveAgentState(rc, agent); - final int sizeBefore = state != null ? state.contextMutable().size() : -1; - return next.apply(input).doOnComplete(() -> evictAddedToolResults(agent, rc, sizeBefore)); + evictOversizedToolResults(agent, rc); + return next.apply(input); } - private void evictAddedToolResults(Agent agent, RuntimeContext rc, int sizeBefore) { + private void evictOversizedToolResults(Agent agent, RuntimeContext rc) { AgentState state = RuntimeContext.resolveAgentState(rc, agent); - if (state == null || sizeBefore < 0) { + if (state == null) { return; } List ctx = state.contextMutable(); String agentName = agent.getName(); - for (int i = sizeBefore; i < ctx.size(); i++) { + for (int i = 0; i < ctx.size(); i++) { Msg msg = ctx.get(i); if (msg == null || msg.getRole() != MsgRole.TOOL) { continue; From e01cf121471888383efad706720bc47a2257721b Mon Sep 17 00:00:00 2001 From: Chickenlj Date: Wed, 10 Jun 2026 17:11:55 +0800 Subject: [PATCH 04/11] fix(agent): improve state loading logic for distributed deployments in activateSlotForContext --- .../java/io/agentscope/core/ReActAgent.java | 50 +++++++++++++------ 1 file changed, 35 insertions(+), 15 deletions(-) diff --git a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java index e2af9a631e..0fd76fed63 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -424,8 +424,14 @@ private Mono saveStateToSession(CallExecution scope) { /** * Per-call slot activation. Reads {@code (userId, sessionId)} from the given RuntimeContext * (falling back to {@link #defaultSessionId} when absent), and atomically swaps the active - * {@link #state} + {@link #permissionEngine} to that slot's cached entries (loading them on - * first use). Safe to call from {@code beforeAgentExecution} only — caller must hold the + * {@code #state} + {@code #permissionEngine} to that slot's cached entries. + * + *

      When a {@link AgentStateStore} is configured the state is always reloaded from the store + * at the beginning of each call so that distributed deployments (where the same sessionId may + * drift across machines) see the latest persisted state rather than a stale local cache entry. + * The per-call cost of one store read is negligible compared to the LLM round-trip. + * + *

      Safe to call from {@code beforeAgentExecution} only — caller must hold the * {@code AgentBase.acquireExecution} lock. */ private CallExecution activateSlotForContext(RuntimeContext ctx) { @@ -437,19 +443,33 @@ private CallExecution activateSlotForContext(RuntimeContext ctx) { String slot = slotKey(uid, sid); final String finalUid = uid; final String finalSid = sid; - AgentState loaded = - stateCache.computeIfAbsent( - slot, - k -> - loadOrCreateAgentStateForSlot( - stateStore, - finalUid, - finalSid, - initialPermissionContext, - getAgentId())); - PermissionEngine loadedEngine = - permissionEngineCache.computeIfAbsent( - slot, k -> new PermissionEngine(loaded.getPermissionContext())); + AgentState loaded; + if (stateStore != null) { + loaded = + loadOrCreateAgentStateForSlot( + stateStore, finalUid, finalSid, initialPermissionContext, getAgentId()); + stateCache.put(slot, loaded); + } else { + loaded = + stateCache.computeIfAbsent( + slot, + k -> + loadOrCreateAgentStateForSlot( + null, + finalUid, + finalSid, + initialPermissionContext, + getAgentId())); + } + PermissionEngine loadedEngine; + if (stateStore != null) { + loadedEngine = new PermissionEngine(loaded.getPermissionContext()); + permissionEngineCache.put(slot, loadedEngine); + } else { + loadedEngine = + permissionEngineCache.computeIfAbsent( + slot, k -> new PermissionEngine(loaded.getPermissionContext())); + } CallExecution scope = new CallExecution(loaded, loadedEngine, slot); if (toolkit != null) { toolkit.setActiveGroups(loaded.getToolContext().getActivatedGroups()); From 84e480b51d1505b6d031c3126d06aee07d7d1431 Mon Sep 17 00:00:00 2001 From: Chickenlj Date: Wed, 10 Jun 2026 17:15:03 +0800 Subject: [PATCH 05/11] feat(filesystem): add WorkspacePathNormalizer for workspace-relative path normalization --- .../harness/agent/HarnessAgent.java | 47 ++++++-- .../harness/agent/tool/FilesystemTool.java | 24 +++- .../workspace/WorkspacePathNormalizer.java | 106 ++++++++++++++++++ docs/v2/en/docs/harness/skill.md | 2 + docs/v2/zh/docs/harness/skill.md | 2 + 5 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspacePathNormalizer.java diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java index 170c18ae57..97667c37d6 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java @@ -83,6 +83,7 @@ import io.agentscope.harness.agent.skill.curator.SkillPromotionGate; import io.agentscope.harness.agent.skill.curator.SkillUsageStore; import io.agentscope.harness.agent.skill.curator.SkillVisibilityFilter; +import io.agentscope.harness.agent.skill.runtime.ShellPathPolicy; import io.agentscope.harness.agent.subagent.SubagentDeclaration; import io.agentscope.harness.agent.subagent.task.TaskRepository; import io.agentscope.harness.agent.tool.FilesystemTool; @@ -100,6 +101,7 @@ import io.agentscope.harness.agent.tools.ToolsConfigLoader; import io.agentscope.harness.agent.workspace.WorkspaceIndex; import io.agentscope.harness.agent.workspace.WorkspaceManager; +import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer; import io.agentscope.harness.agent.workspace.plan.PlanModeManager; import java.nio.file.Path; import java.nio.file.Paths; @@ -171,6 +173,8 @@ public class HarnessAgent implements Agent, AutoCloseable { */ private final DistributedStore distributedStore; + private final WorkspacePathNormalizer pathNormalizer; + /** Lazily created internal gateway for {@link #channel}. */ private volatile HarnessGateway internalGateway; @@ -190,7 +194,8 @@ private HarnessAgent( SkillAuditLog skillAuditLog, MemoryConfig memoryConfig, Object subagentMiddleware, - DistributedStore distributedStore) { + DistributedStore distributedStore, + WorkspacePathNormalizer pathNormalizer) { this.delegate = delegate; this.workspaceManager = workspaceManager; this.workspaceFactory = workspaceFactory; @@ -208,6 +213,7 @@ private HarnessAgent( this.memoryConfig = memoryConfig != null ? memoryConfig : MemoryConfig.defaults(); this.subagentMiddleware = subagentMiddleware; this.distributedStore = distributedStore; + this.pathNormalizer = pathNormalizer; } /** Returns the workspace manager bound to this agent, or {@code null} if not configured. */ @@ -803,17 +809,29 @@ private RuntimeContext ensureSessionDefaults(RuntimeContext ctx) { ctx.get(SandboxContext.class) != null ? ctx.get(SandboxContext.class) : defaultSandboxContext; + AbstractFilesystem fs = workspaceManager != null ? workspaceManager.getFilesystem() : null; if (ctxSessionId.equals(ctx.getSessionId()) - && sandboxCtx == ctx.get(SandboxContext.class)) { + && sandboxCtx == ctx.get(SandboxContext.class) + && (fs == null || fs == ctx.get(AbstractFilesystem.class))) { return ctx; } - return RuntimeContext.builder() - .sessionId(ctxSessionId) - .userId(ctx.getUserId()) - .putAll(ctx.getExtra()) - .put(SandboxContext.class, sandboxCtx) - .build(); + RuntimeContext.Builder b = + RuntimeContext.builder() + .sessionId(ctxSessionId) + .userId(ctx.getUserId()) + .putAll(ctx.getExtra()) + .put(SandboxContext.class, sandboxCtx); + if (fs != null) { + b.put(AbstractFilesystem.class, fs); + } + if (workspaceManager != null) { + b.put(WorkspaceManager.class, workspaceManager); + } + if (pathNormalizer != null) { + b.put(WorkspacePathNormalizer.class, pathNormalizer); + } + return b.build(); } private Mono recoverFromOverflow(List msgs, RuntimeContext effective) { @@ -1992,8 +2010,16 @@ public HarnessAgent build() { agentToolkit.registerTool(new MemoryGetTool(wsManager)); agentToolkit.registerTool(new SessionSearchTool(wsManager)); } + WorkspacePathNormalizer pathNormalizer; + if (filesystem instanceof AbstractSandboxFilesystem) { + pathNormalizer = + WorkspacePathNormalizer.of(ShellPathPolicy.SANDBOX_WORKSPACE_PREFIX); + } else { + pathNormalizer = + WorkspacePathNormalizer.of(resolvedWorkspace.toAbsolutePath().toString()); + } if (!disableFilesystemTools) { - agentToolkit.registerTool(new FilesystemTool(filesystem)); + agentToolkit.registerTool(new FilesystemTool(filesystem, pathNormalizer)); } if (!disableShellTool && filesystem instanceof AbstractSandboxFilesystem sandbox) { agentToolkit.registerTool(new ShellExecuteTool(sandbox)); @@ -2218,7 +2244,8 @@ public HarnessAgent build() { pendingSkillAuditLog, memoryConfig, capturedSubagentMw, - distributedStore); + distributedStore, + pathNormalizer); } } } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/FilesystemTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/FilesystemTool.java index e5cabcfde5..95200a70b6 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/FilesystemTool.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/FilesystemTool.java @@ -27,6 +27,7 @@ import io.agentscope.harness.agent.filesystem.model.LsResult; import io.agentscope.harness.agent.filesystem.model.ReadResult; import io.agentscope.harness.agent.filesystem.model.WriteResult; +import io.agentscope.harness.agent.workspace.WorkspacePathNormalizer; import java.util.List; import java.util.stream.Collectors; @@ -37,9 +38,20 @@ public class FilesystemTool { private final AbstractFilesystem abstractFilesystem; + private final WorkspacePathNormalizer pathNormalizer; public FilesystemTool(AbstractFilesystem abstractFilesystem) { + this(abstractFilesystem, null); + } + + public FilesystemTool( + AbstractFilesystem abstractFilesystem, WorkspacePathNormalizer pathNormalizer) { this.abstractFilesystem = abstractFilesystem; + this.pathNormalizer = pathNormalizer; + } + + private String norm(String path) { + return pathNormalizer != null ? pathNormalizer.normalize(path) : path; } @Tool( @@ -57,7 +69,7 @@ public String readFile( int offset, @ToolParam(name = "limit", description = "Max lines to return. Default: 0 (all lines)") int limit) { - ReadResult r = abstractFilesystem.read(runtimeContext, path, offset, limit); + ReadResult r = abstractFilesystem.read(runtimeContext, norm(path), offset, limit); if (!r.isSuccess()) { return "Error: " + r.error(); } @@ -71,7 +83,7 @@ public String writeFile( RuntimeContext runtimeContext, @ToolParam(name = "path", description = "Target file path") String path, @ToolParam(name = "content", description = "File content to write") String content) { - WriteResult r = abstractFilesystem.write(runtimeContext, path, content); + WriteResult r = abstractFilesystem.write(runtimeContext, norm(path), content); return r.isSuccess() ? "Written to " + r.path() : "Error: " + r.error(); } @@ -93,7 +105,7 @@ public String editFile( boolean shouldReplaceAll = Boolean.TRUE.equals(replaceAll); EditResult r = abstractFilesystem.edit( - runtimeContext, path, oldString, newString, shouldReplaceAll); + runtimeContext, norm(path), oldString, newString, shouldReplaceAll); return r.isSuccess() ? "Edited " + r.path() + " (" + r.occurrences() + " replacement(s))" : "Error: " + r.error(); @@ -110,7 +122,7 @@ public String grepFiles( @ToolParam(name = "path", description = "Directory or file to search") String path, @ToolParam(name = "glob", description = "Optional file glob filter (e.g., *.java)") String glob) { - GrepResult r = abstractFilesystem.grep(runtimeContext, pattern, path, glob); + GrepResult r = abstractFilesystem.grep(runtimeContext, pattern, norm(path), glob); if (!r.isSuccess()) { return "Error: " + r.error(); } @@ -129,7 +141,7 @@ public String globFiles( @ToolParam(name = "pattern", description = "Glob pattern (e.g., **/*.java)") String pattern, @ToolParam(name = "path", description = "Base directory to search from") String path) { - GlobResult r = abstractFilesystem.glob(runtimeContext, pattern, path); + GlobResult r = abstractFilesystem.glob(runtimeContext, pattern, norm(path)); if (!r.isSuccess()) { return "Error: " + r.error(); } @@ -149,7 +161,7 @@ public String globFiles( public String listFiles( RuntimeContext runtimeContext, @ToolParam(name = "path", description = "Directory path to list") String path) { - LsResult r = abstractFilesystem.ls(runtimeContext, path); + LsResult r = abstractFilesystem.ls(runtimeContext, norm(path)); if (!r.isSuccess()) { return "Error: " + r.error(); } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspacePathNormalizer.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspacePathNormalizer.java new file mode 100644 index 0000000000..1319188b4c --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspacePathNormalizer.java @@ -0,0 +1,106 @@ +/* + * Copyright 2024-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 + * + * 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 io.agentscope.harness.agent.workspace; + +import java.util.ArrayList; +import java.util.List; + +/** + * Normalizes file paths to workspace-relative form by stripping the active mode's workspace + * prefix. + * + *

      Only the prefix matching the current filesystem mode is registered, so there is no risk + * of a sandbox prefix ({@code /workspace/}) accidentally matching a real host directory in + * local mode, or vice versa. + * + *

      Paths that don't match any registered prefix pass through unchanged, preserving the + * ability to access non-workspace files in modes that allow it. + */ +public final class WorkspacePathNormalizer { + + private final List prefixes; + + private WorkspacePathNormalizer(List prefixes) { + this.prefixes = List.copyOf(prefixes); + } + + /** + * Creates a normalizer that strips the given prefix. + * + * @param workspacePrefix the workspace root path for the active mode (e.g. + * {@code "/workspace"} for sandbox, or the host workspace absolute path for local) + */ + public static WorkspacePathNormalizer of(String workspacePrefix) { + List list = new ArrayList<>(1); + String trimmed = trimTrailingSlash(workspacePrefix); + if (trimmed != null && !trimmed.isEmpty()) { + list.add(trimmed); + } + return new WorkspacePathNormalizer(list); + } + + /** + * Creates a normalizer that tries multiple prefixes in order. Use only when the active + * mode has more than one valid prefix (e.g. local-with-shell where project dir and + * workspace dir are both valid roots). + */ + public static WorkspacePathNormalizer of(String... workspacePrefixes) { + List list = new ArrayList<>(workspacePrefixes.length); + for (String p : workspacePrefixes) { + String trimmed = trimTrailingSlash(p); + if (trimmed != null && !trimmed.isEmpty()) { + list.add(trimmed); + } + } + return new WorkspacePathNormalizer(list); + } + + /** + * Normalize a path to workspace-relative form by stripping the active mode's prefix. + * + * @param path the raw path (absolute or relative) + * @return workspace-relative path, or the original path if no registered prefix matched + */ + public String normalize(String path) { + if (path == null || path.isBlank()) { + return path; + } + for (String prefix : prefixes) { + String stripped = tryStrip(path, prefix); + if (stripped != null) { + return stripped; + } + } + return path; + } + + private static String tryStrip(String path, String prefix) { + if (path.startsWith(prefix + "/")) { + return path.substring(prefix.length() + 1); + } + if (path.equals(prefix)) { + return "."; + } + return null; + } + + private static String trimTrailingSlash(String s) { + if (s != null && s.length() > 1 && s.endsWith("/")) { + return s.substring(0, s.length() - 1); + } + return s; + } +} diff --git a/docs/v2/en/docs/harness/skill.md b/docs/v2/en/docs/harness/skill.md index 23db22304c..fb16d0af2c 100644 --- a/docs/v2/en/docs/harness/skill.md +++ b/docs/v2/en/docs/harness/skill.md @@ -403,6 +403,8 @@ A common point of confusion: **reading** a skill (`load_skill_through_path` fetc **Keep `SKILL.md` lean.** Aim for ≤ 2k tokens; put reference material under `references/`, scripts under `scripts/`. The agent reads them on demand. +**Use relative paths in SKILL.md and scripts.** Due to the multi-layer isolation of the abstract filesystem, always reference resources and scripts using paths relative to SKILL.md (e.g. `scripts/run.py`, `references/guide.md`). **Do not** hard-code absolute paths like `/workspace/scripts/run.py`. The framework automatically generates the correct `` absolute path prefix for each skill based on the active filesystem mode, and the agent uses `` to construct full paths at shell-execution time. Hard-coded absolute paths make a skill work only under a specific filesystem mode. + **General capability in marketplaces, project-specific in the workspace.** Code review, table analysis → team Git for shared maintenance. Internal RPC conventions, project naming rules → `workspace/skills/` so they version with the code. **Per-user dirs are for "override + augment", not primary storage.** Keep critical skills visible to every user. diff --git a/docs/v2/zh/docs/harness/skill.md b/docs/v2/zh/docs/harness/skill.md index a92d32df3b..66d43f412f 100644 --- a/docs/v2/zh/docs/harness/skill.md +++ b/docs/v2/zh/docs/harness/skill.md @@ -403,6 +403,8 @@ execute_shell_command("python3 /workspace/skills/code-reviewer/scripts/run-check **`SKILL.md` 保持精简。** 控制在 2k tokens 上下,详细参考资料放 `references/`,脚本放 `scripts/`。agent 需要时会自己读。 +**SKILL.md 和脚本中只使用相对路径。** 由于抽象文件系统多层隔离的特殊性,SKILL.md 中引用资源和脚本时请使用相对于 SKILL.md 的路径(如 `scripts/run.py`、`references/guide.md`),**不要**硬编码绝对路径(如 `/workspace/scripts/run.py`)。框架会根据当前文件系统模式自动为每个 skill 生成正确的 `` 绝对路径前缀,agent 在 shell 执行时会用 `` 拼出完整路径。硬编码绝对路径会导致 skill 只能在特定文件系统模式下工作。 + **通用能力放市场,项目特有的写工作区。** 代码评审、表格分析这种放团队 Git 上集中维护;公司内部 RPC 规范、本项目的命名约定写到 `workspace/skills/` 里跟着代码版本走。 **用户目录用来"覆盖+补充",不要拿来当主存放。** 关键能力请放在所有用户都能看到的层。 From 8b398db624b24348a1411cf86c1a58034d415c99 Mon Sep 17 00:00:00 2001 From: Chickenlj Date: Wed, 10 Jun 2026 18:33:57 +0800 Subject: [PATCH 06/11] refactor(filesystem): simplify path resolution logic in LocalFilesystem --- .../filesystem/local/LocalFilesystem.java | 38 +++++-------------- 1 file changed, 9 insertions(+), 29 deletions(-) diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/local/LocalFilesystem.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/local/LocalFilesystem.java index d52581af2e..243e17940a 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/local/LocalFilesystem.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/local/LocalFilesystem.java @@ -448,14 +448,7 @@ public GlobResult glob(RuntimeContext runtimeContext, String pattern, String pat public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { Path rel = searchPath.relativize(file); if (matcher.matches(rel) || directMatcher.matches(rel)) { - String filePath; - if (mode == LocalFsMode.SANDBOXED) { - filePath = toVirtualPath(file); - } else if (hasNamespace(runtimeContext)) { - filePath = stripNamespacePrefix(runtimeContext, file); - } else { - filePath = file.toAbsolutePath().toString(); - } + String filePath = resolveEntryPath(runtimeContext, file); String modifiedAt = Instant.ofEpochMilli(attrs.lastModifiedTime().toMillis()) .toString(); @@ -694,14 +687,15 @@ private String resolveEntryPath(RuntimeContext rc, Path entry) { if (hasNamespace(rc)) { return stripNamespacePrefix(rc, entry); } - return entry.toAbsolutePath().toString(); + return toCwdRelativePath(entry); + } + + private String toCwdRelativePath(Path path) { + return cwd.relativize(path.toAbsolutePath().normalize()).toString().replace('\\', '/'); } private String stripNamespacePrefix(RuntimeContext rc, Path absolutePath) { - String relPath = - cwd.relativize(absolutePath.toAbsolutePath().normalize()) - .toString() - .replace('\\', '/'); + String relPath = toCwdRelativePath(absolutePath); String nsPrefix = String.join("/", namespaceFactory.getNamespace(rc)); if (relPath.startsWith(nsPrefix + "/")) { return relPath.substring(nsPrefix.length() + 1); @@ -766,14 +760,7 @@ private GrepMatch parseRipgrepJsonLine(RuntimeContext rc, String jsonLine) { if (pathText == null || lineNumStr == null) { return null; } - String filePath; - if (mode == LocalFsMode.SANDBOXED) { - filePath = toVirtualPath(Path.of(pathText)); - } else if (hasNamespace(rc)) { - filePath = stripNamespacePrefix(rc, Path.of(pathText)); - } else { - filePath = pathText; - } + String filePath = resolveEntryPath(rc, Path.of(pathText)); int lineNum = Integer.parseInt(lineNumStr.trim()); String text = linesText != null ? linesText.replaceAll("[\r\n]+$", "") : ""; return new GrepMatch(filePath, lineNum, text); @@ -856,14 +843,7 @@ private List javaSearch( Files.readAllLines(file, StandardCharsets.UTF_8); for (int i = 0; i < lines.size(); i++) { if (compiledPattern.matcher(lines.get(i)).find()) { - String filePath; - if (mode == LocalFsMode.SANDBOXED) { - filePath = toVirtualPath(file); - } else if (hasNamespace(rc)) { - filePath = stripNamespacePrefix(rc, file); - } else { - filePath = file.toAbsolutePath().toString(); - } + String filePath = resolveEntryPath(rc, file); matches.add( new GrepMatch(filePath, i + 1, lines.get(i))); } From 770919c90273cb5fe958ab58ffbd90b295548650 Mon Sep 17 00:00:00 2001 From: Chickenlj Date: Wed, 10 Jun 2026 18:34:42 +0800 Subject: [PATCH 07/11] refactor(filesystem): simplify path resolution logic in LocalFilesystem --- .../agentscope/harness/agent/filesystem/FilesystemGlobTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/FilesystemGlobTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/FilesystemGlobTest.java index 8add90ed0a..7121d67c01 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/FilesystemGlobTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/FilesystemGlobTest.java @@ -51,7 +51,7 @@ void local_glob_plainPattern_matchesFilesInSearchRootAndSubdirectories(@TempDir assertTrue(result.isSuccess()); Set relPaths = result.matches().stream() - .map(fi -> tmp.relativize(Path.of(fi.path())).toString().replace('\\', '/')) + .map(fi -> fi.path().replace('\\', '/')) .collect(Collectors.toSet()); assertEquals(Set.of("memory/2026-05-13.md", "memory/sub/2026-05-14.md"), relPaths); From c01610af3b34fe913158471e8fc7d59d6166b3dd Mon Sep 17 00:00:00 2001 From: Chickenlj Date: Thu, 11 Jun 2026 09:30:18 +0800 Subject: [PATCH 08/11] add license --- .../quickstart/UserIsolatedMultiTurnsExample.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/quickstart/UserIsolatedMultiTurnsExample.java b/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/quickstart/UserIsolatedMultiTurnsExample.java index 1398f067c0..b3ea76ea75 100644 --- a/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/quickstart/UserIsolatedMultiTurnsExample.java +++ b/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/quickstart/UserIsolatedMultiTurnsExample.java @@ -1,3 +1,18 @@ +/* + * Copyright 2024-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 + * + * 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 io.agentscope.examples.documentation2.quickstart; import io.agentscope.core.agent.RuntimeContext; From 61db53a3b89a9995052a297a7a446675e67d1dd4 Mon Sep 17 00:00:00 2001 From: Chickenlj Date: Thu, 11 Jun 2026 10:01:55 +0800 Subject: [PATCH 09/11] fix(tests): update RuntimeContext initialization to set userId for accurate isolation --- .../agent/example/LocalFilesystemUserIsolationExampleTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemUserIsolationExampleTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemUserIsolationExampleTest.java index ccfd41487c..0e2462cef0 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemUserIsolationExampleTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemUserIsolationExampleTest.java @@ -126,7 +126,7 @@ void globRoundTrip_pathsAreRoundTrippable() throws Exception { // Write files as alice agent.call(userMsg("hi"), ctx("s1", "alice")).block(); AbstractFilesystem fs = agent.getWorkspaceManager().getFilesystem(); - RuntimeContext rt = RuntimeContext.empty(); + RuntimeContext rt = RuntimeContext.builder().userId("alice").build(); fs.uploadFiles( rt, From a56d4e357086775c636390f977df752aab81fffe Mon Sep 17 00:00:00 2001 From: Chickenlj Date: Thu, 11 Jun 2026 10:14:45 +0800 Subject: [PATCH 10/11] code format --- .../UserIsolatedMultiTurnsExample.java | 83 ++++++++++--------- 1 file changed, 46 insertions(+), 37 deletions(-) diff --git a/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/quickstart/UserIsolatedMultiTurnsExample.java b/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/quickstart/UserIsolatedMultiTurnsExample.java index b3ea76ea75..a772502a8b 100644 --- a/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/quickstart/UserIsolatedMultiTurnsExample.java +++ b/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/quickstart/UserIsolatedMultiTurnsExample.java @@ -22,7 +22,6 @@ import io.agentscope.core.model.Model; import io.agentscope.harness.agent.HarnessAgent; import io.agentscope.harness.agent.memory.compaction.CompactionConfig; - import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -35,50 +34,58 @@ public static void main(String[] args) throws Exception { initWorkspaceIfAbsent(workspace); // 2. Build model - Model model = DashScopeChatModel.builder() - .apiKey(System.getenv("DASHSCOPE_API_KEY")) - .modelName("qwen-max") - .stream(true) - .build(); + Model model = + DashScopeChatModel.builder() + .apiKey(System.getenv("DASHSCOPE_API_KEY")) + .modelName("qwen-max") + .stream(true) + .build(); // 3. Build HarnessAgent: workspace injection, session persistence, and trace logging // are enabled by default; compaction is explicitly configured here - HarnessAgent agent = HarnessAgent.builder() - .name("quickstart-agent") - .sysPrompt("You are a note-taking assistant.") - .model(model) - .workspace(workspace) - .compaction(CompactionConfig.builder() - .triggerMessages(30) - .keepMessages(10) - .flushBeforeCompact(true) // extract facts to daily log before compacting - .build()) - .build(); + HarnessAgent agent = + HarnessAgent.builder() + .name("quickstart-agent") + .sysPrompt("You are a note-taking assistant.") + .model(model) + .workspace(workspace) + .compaction( + CompactionConfig.builder() + .triggerMessages(30) + .keepMessages(10) + .flushBeforeCompact( + true) // extract facts to daily log before + // compacting + .build()) + .build(); // 4. Two conversation turns with the same RuntimeContext // Same sessionId → turn 2 auto-restores state from turn 1 - RuntimeContext ctx = RuntimeContext.builder() - .sessionId("demo-session") - .userId("alice") - .build(); + RuntimeContext ctx = + RuntimeContext.builder().sessionId("demo-session").userId("alice").build(); - Msg turn1 = agent.call( - Msg.builder().role(MsgRole.USER) - .textContent("My name is Alice, and I'm preparing a tech talk on ReAct today. Remember this!") - .build(), - ctx).block(); + Msg turn1 = + agent.call( + Msg.builder() + .role(MsgRole.USER) + .textContent( + "My name is Alice, and I'm preparing a tech talk on" + + " ReAct today. Remember this!") + .build(), + ctx) + .block(); System.out.println("[turn1] " + turn1.getTextContent()); -// - ctx = RuntimeContext.builder() - .sessionId("demo-session") - .userId("ken") - .build(); - Msg turn2 = agent.call( - Msg.builder().role(MsgRole.USER) - .textContent("I like eating apples. Remember this!") - .build(), - ctx).block(); + // + ctx = RuntimeContext.builder().sessionId("demo-session").userId("ken").build(); + Msg turn2 = + agent.call( + Msg.builder() + .role(MsgRole.USER) + .textContent("I like eating apples. Remember this!") + .build(), + ctx) + .block(); System.out.println("[turn2] " + turn2.getTextContent()); // wait 5 seconds for asynchronous memory flush to write @@ -89,7 +96,9 @@ private static void initWorkspaceIfAbsent(Path workspace) throws Exception { Files.createDirectories(workspace); Path agentsMd = workspace.resolve("AGENTS.md"); if (Files.exists(agentsMd)) return; - Files.writeString(agentsMd, """ + Files.writeString( + agentsMd, + """ # Note-taking Assistant You are an assistant that helps users organize notes and knowledge. From b25bb4b176824716d0957922c03fde5e9d860079 Mon Sep 17 00:00:00 2001 From: Chickenlj Date: Thu, 11 Jun 2026 10:28:39 +0800 Subject: [PATCH 11/11] docs: add 2.0.0-RC3 release notes --- docs/v2/en/docs/others/release-notes.md | 25 +++++++++++++++++++++++++ docs/v2/zh/docs/others/release-notes.md | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/docs/v2/en/docs/others/release-notes.md b/docs/v2/en/docs/others/release-notes.md index c53c40c3cb..48b7557a61 100644 --- a/docs/v2/en/docs/others/release-notes.md +++ b/docs/v2/en/docs/others/release-notes.md @@ -7,6 +7,31 @@ This page tracks per-version changes for AgentScope Java 2.0. For the overall mi --- +## 2.0.0-RC3 + +> Released: 2026-06-11 + +### Added + +- **`AgentResultEvent`** — new event type emitted when an agent finishes processing, immediately before `AgentEndEvent`, carrying the final `Msg` result. Consumers of `streamEvents()` can obtain the result directly from the event stream without separately subscribing to the `Mono` return value +- **`CustomEvent`** — generic extensible event for middleware to push application-level notifications (state changes, team updates, etc.) to front-end subscribers without adding per-use-case `AgentEventType` entries. Built-in well-known names: `state_updated`, `team_updated` +- **`HintBlockEvent`** — one-shot hint block event for delivering complete content such as team messages, background tool results, and user interruptions, as opposed to streamed text/thinking blocks +- **`WorkspacePathNormalizer`** — file path normalization utility that converts absolute paths to workspace-relative form. Registers prefixes based on the active filesystem mode (local / sandbox) to prevent cross-mode prefix collisions +- **`toolCallName` on tool events** — `ToolCallDeltaEvent`, `ToolCallEndEvent`, `ToolResultDataDeltaEvent`, `ToolResultEndEvent`, and `ToolResultTextDeltaEvent` now carry a `toolCallName` field, so consumers no longer need to cache the name mapping from the start event + +### Changed + +- **Unified `call()` / `streamEvents()` core** — introduced an internal `buildAgentStream` method as the shared implementation for both `call()` and `streamEvents()`, ensuring the `onAgent` middleware chain fires consistently on all invocation paths. `call()` now extracts the result from `AgentResultEvent` in the event stream; the legacy standalone `agentImpl` logic has been removed +- **Session state always reloaded from store in distributed deployments** — when an `AgentStateStore` is configured, `activateSlotForContext` now reloads the agent state and permission engine from the store at the start of every call, preventing stale local cache reads when the same sessionId drifts across machines +- **`ToolResultEvictionMiddleware` timing fix** — moved from `onActing` (where state had not yet been written, making eviction a no-op) to `onReasoning`, ensuring tool results are persisted before eviction runs +- **Simplified `LocalFilesystem` path resolution** — refactored path resolution logic to reduce redundant code + +### Fixed + +- Fixed `RuntimeContext` not setting `userId` in tests, causing inaccurate user isolation + +--- + ## 2.0.0-RC2 > Released: 2026-06-09 diff --git a/docs/v2/zh/docs/others/release-notes.md b/docs/v2/zh/docs/others/release-notes.md index e8b3eed35b..b90ece14bb 100644 --- a/docs/v2/zh/docs/others/release-notes.md +++ b/docs/v2/zh/docs/others/release-notes.md @@ -7,6 +7,31 @@ description: "AgentScope Java 各版本变更记录" --- +## 2.0.0-RC3 + +> 发布日期:2026-06-11 + +### 新增 + +- **`AgentResultEvent`** —— 新增事件类型,在 agent 调用完成后、`AgentEndEvent` 之前发出,携带最终 `Msg` 结果。`streamEvents()` 的消费方可直接从事件流中获取最终结果,无需额外订阅 `Mono` 返回值 +- **`CustomEvent`** —— 通用可扩展事件,用于中间件向前端推送应用级通知(状态变更、团队变更等),无需为每种业务场景新增 `AgentEventType`。内置 well-known name:`state_updated`、`team_updated` +- **`HintBlockEvent`** —— 一次性 hint block 事件,用于传递团队消息、后台工具结果、用户中断等完整内容,区别于需要流式拼接的 text/thinking block +- **`WorkspacePathNormalizer`** —— 文件路径归一化工具,将绝对路径转换为 workspace 相对路径。根据当前文件系统模式(本地 / 沙箱)注册前缀,避免跨模式误匹配 +- **工具事件携带 `toolCallName`** —— `ToolCallDeltaEvent`、`ToolCallEndEvent`、`ToolResultDataDeltaEvent`、`ToolResultEndEvent`、`ToolResultTextDeltaEvent` 均新增 `toolCallName` 字段,消费端不再需要缓存 start 事件的名称映射 + +### 变更 + +- **`call()` 与 `streamEvents()` 共享执行核心** —— 新增内部 `buildAgentStream` 方法作为 `call()` 和 `streamEvents()` 的统一实现,确保 `onAgent` middleware 链在所有调用路径上一致触发。`call()` 从事件流中提取 `AgentResultEvent` 获得结果,移除了旧的独立 `agentImpl` 逻辑 +- **分布式部署下 session 状态始终从 store 加载** —— `activateSlotForContext` 在配置了 `AgentStateStore` 时,每次调用开头从 store 重新加载状态和权限引擎,避免分布式环境中同一 sessionId 漂移到不同机器时读到过期本地缓存 +- **`ToolResultEvictionMiddleware` 时机修正** —— 从 `onActing`(此时状态尚未写入,导致空操作)迁移到 `onReasoning`,确保工具结果已持久化后再执行淘汰 +- **`LocalFilesystem` 路径解析简化** —— 重构路径解析逻辑,减少冗余代码 + +### 修复 + +- 修复 `RuntimeContext` 在测试中未设置 `userId` 导致用户隔离不准确的问题 + +--- + ## 2.0.0-RC2 > 发布日期:2026-06-09