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..0fd76fed63 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; @@ -422,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) { @@ -435,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()); @@ -603,15 +625,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 +754,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 +802,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 +1648,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) { @@ -1993,7 +2042,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()) @@ -2020,7 +2069,7 @@ private Flux modelCallStream( thinkingStarted, withToolEvents ? startedToolCalls - : ConcurrentHashMap.newKeySet(), + : new ConcurrentHashMap<>(), events); } return Flux.fromIterable(events); @@ -2036,8 +2085,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); @@ -2052,7 +2103,7 @@ private void emitBlockEvents( ReasoningContext context, AtomicBoolean textStarted, AtomicBoolean thinkingStarted, - Set startedToolCalls, + Map startedToolCalls, List events) { if (block instanceof TextBlock tb) { @@ -2071,8 +2122,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)); } @@ -2080,7 +2131,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())); } } } @@ -2324,10 +2378,12 @@ private Flux runToolBatch( new ToolResultTextDeltaEvent( replyId, use.getId(), + use.getName(), "Permission denied by user"), new ToolResultEndEvent( replyId, use.getId(), + use.getName(), ToolResultState.DENIED)); }); @@ -2355,9 +2411,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 @@ -2368,6 +2430,8 @@ private Flux runToolBatch( replyId, toolUse .getId(), + toolUse + .getName(), tb .getText())); } else { @@ -2376,6 +2440,8 @@ private Flux runToolBatch( replyId, toolUse .getId(), + toolUse + .getName(), block)); } } @@ -2403,6 +2469,11 @@ private Flux runToolBatch( ToolUseBlock, ToolResultBlock> entry : results) { + emitToolResultDelta( + sink, + replyId, + entry, + chunkedToolIds); ToolResultState state = determineToolResultState( entry @@ -2412,6 +2483,8 @@ private Flux runToolBatch( replyId, entry.getKey() .getId(), + entry.getKey() + .getName(), state)); } sink.complete(); @@ -2509,6 +2582,35 @@ 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(); + String toolName = entry.getKey().getName(); + 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, toolName, tb.getText())); + } else { + sink.next(new ToolResultDataDeltaEvent(replyId, toolId, toolName, block)); + } + } + } + private ToolResultState determineToolResultState(ToolResultBlock result) { if (result.isSuspended()) { return ToolResultState.RUNNING; @@ -3187,7 +3289,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/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..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; /** @@ -34,6 +36,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"), @@ -62,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 { @@ -70,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(); @@ -108,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 0b6d483cb1..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 @@ -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"), @@ -82,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/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-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: + *

+ */ +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/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/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; + } } 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-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..a772502a8b --- /dev/null +++ b/agentscope-examples/documentation/src/main/java/io/agentscope/examples/documentation2/quickstart/UserIsolatedMultiTurnsExample.java @@ -0,0 +1,112 @@ +/* + * 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; +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..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) { @@ -1905,6 +1923,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 +1941,8 @@ public HarnessAgent build() { wsManager, memoryModel, effectiveFlushPrompt, - memoryConfig.flushTrigger())); + memoryConfig.flushTrigger(), + effectiveIsolationScope)); String effectiveConsolidationPrompt = memoryConfig.consolidationPrompt() != null @@ -1932,7 +1960,8 @@ public HarnessAgent build() { consolidator, memoryConfig.dailyFileRetentionDays(), memoryConfig.sessionRetentionDays(), - memoryConfig.consolidationMinGap())); + memoryConfig.consolidationMinGap(), + effectiveIsolationScope)); } CompactionMiddleware compactionHook = null; if (compactionConfig != null) { @@ -1981,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)); @@ -2207,7 +2244,8 @@ public HarnessAgent build() { pendingSkillAuditLog, memoryConfig, capturedSubagentMw, - distributedStore); + distributedStore, + pathNormalizer); } } } 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))); } 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/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; 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/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/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, 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); 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"); + } } 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/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/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/` 里跟着代码版本走。 **用户目录用来"覆盖+补充",不要拿来当主存放。** 关键能力请放在所有用户都能看到的层。 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