diff --git a/.gitignore b/.gitignore index 67744302e..fa64ecd7b 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,9 @@ logs/ **/boba-tea-shop/supervisor-agent/**/static/ **/boba-tea-shop/**/node_modules/ **/boba-tea-shop/**/dist/ + +##agentscope +.agentscope/ + +## harness db +**/*.db diff --git a/.licenserc.yaml b/.licenserc.yaml index 8ad0920c2..0523beec0 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -17,6 +17,11 @@ header: limitations under the License. paths-ignore: + # Config / example files without license headers + - 'agentscope-examples/harness-example/.env.example' + - 'agentscope-examples/harness-example/src/main/resources/agentscope.json.example' + - 'agentscope-examples/harness-example/src/main/resources/log4j2.xml' + - 'agentscope-harness/src/main/resources/agentscope.json.example' - '**/*.versionsBackup' - '**/.idea/' - '**/*.iml' 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 efb366362..d65968f0e 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/ReActAgent.java @@ -15,6 +15,10 @@ */ package io.agentscope.core; +import com.fasterxml.jackson.databind.JsonNode; +import io.agentscope.core.agent.Event; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.agent.StreamOptions; import io.agentscope.core.agent.StructuredOutputCapableAgent; import io.agentscope.core.agent.accumulator.ReasoningContext; import io.agentscope.core.hook.ActingChunkEvent; @@ -151,6 +155,17 @@ public class ReActAgent extends StructuredOutputCapableAgent { private final PlanNotebook planNotebook; private final ToolExecutionContext toolExecutionContext; private final StatePersistence statePersistence; + private RuntimeContext pendingRuntimeContext; + + /** + * Per-call system message, propagated across PreCallEvent → PreReasoningEvent / + * PreSummaryEvent. It is safe to use an {@link java.util.concurrent.atomic.AtomicReference} + * here because {@code AgentBase.acquireExecution()} guarantees that only one {@code call()} + * runs concurrently per agent instance, so this reference is effectively owned by a single + * logical execution at any time. + */ + private final java.util.concurrent.atomic.AtomicReference currentSystemMsg = + new java.util.concurrent.atomic.AtomicReference<>(); // ==================== Constructor ==================== @@ -178,6 +193,91 @@ private ReActAgent(Builder builder, Toolkit agentToolkit) { : StatePersistence.all(); } + // ==================== RuntimeContext ==================== + + @Override + protected void beforeAgentExecution(List msgs) { + RuntimeContext ctx = this.pendingRuntimeContext; + this.pendingRuntimeContext = null; + if (ctx == null) { + ctx = RuntimeContext.empty(); + } + bindRuntimeContextToHooks(ctx); + // Reset per-call system message; will be initialised by consumeSystemMsgAfterPreCall + currentSystemMsg.set(null); + } + + @Override + protected Msg seedSystemMsg() { + if (sysPrompt != null && !sysPrompt.trim().isEmpty()) { + return Msg.builder() + .name("system") + .role(MsgRole.SYSTEM) + .content(TextBlock.builder().text(sysPrompt).build()) + .build(); + } + return null; + } + + @Override + protected void consumeSystemMsgAfterPreCall(Msg systemMsg) { + currentSystemMsg.set(systemMsg); + } + + @Override + protected void afterAgentExecution() { + unbindRuntimeContextFromHooks(); + } + + private ToolExecutionContext buildMergedToolContext() { + RuntimeContext run = getRuntimeContext(); + if (run == null) { + return toolExecutionContext != null + ? toolExecutionContext + : ToolExecutionContext.empty(); + } + return ToolExecutionContext.merge(run.asToolExecutionContext(), toolExecutionContext); + } + + /** + * Calls the agent with a per-call {@link RuntimeContext} (metadata for hooks and tools, not + * persisted). + */ + public Mono call(List msgs, RuntimeContext context) { + this.pendingRuntimeContext = context; + return call(msgs); + } + + public Mono call(List msgs, Class structuredOutputClass, RuntimeContext context) { + this.pendingRuntimeContext = context; + return call(msgs, structuredOutputClass); + } + + public Mono call(List msgs, JsonNode outputSchema, RuntimeContext context) { + this.pendingRuntimeContext = context; + return call(msgs, outputSchema); + } + + public Flux stream(List msgs, StreamOptions options, RuntimeContext context) { + this.pendingRuntimeContext = context; + return stream(msgs, options); + } + + public Flux stream( + List msgs, + StreamOptions options, + Class structuredModel, + RuntimeContext context) { + this.pendingRuntimeContext = context; + return stream(msgs, options, structuredModel); + } + + public Flux stream( + List msgs, StreamOptions options, JsonNode schema, RuntimeContext context) { + this.pendingRuntimeContext = context; + return stream(msgs, options, schema); + } + // ==================== New StateModule API ==================== /** @@ -466,17 +566,17 @@ private Mono reasoning(int iter, boolean ignoreMaxIters) { ReasoningContext context = new ReasoningContext(getName()); return checkInterruptedAsync() - .then(notifyPreReasoningEvent(prepareMessages())) + .then(notifyPreReasoningEvent(memory.getMessages())) .flatMapMany( event -> { GenerateOptions options = event.getEffectiveGenerateOptions() != null ? event.getEffectiveGenerateOptions() : buildGenerateOptions(); - return model.stream( - event.getInputMessages(), - toolkit.getToolSchemas(), - options) + List modelInput = + prependSystemMsg( + event.getInputMessages(), event.getSystemMessage()); + return model.stream(modelInput, toolkit.getToolSchemas(), options) .concatMap(chunk -> checkInterruptedAsync().thenReturn(chunk)); }) .doOnNext( @@ -665,7 +765,7 @@ private Msg buildSuspendedMsg(List> pen */ private Mono>> executeToolCalls( List toolCalls) { - return toolkit.callTools(toolCalls, toolExecutionConfig, this, toolExecutionContext) + return toolkit.callTools(toolCalls, toolExecutionConfig, this, buildMergedToolContext()) .map( results -> IntStream.range(0, toolCalls.size()) @@ -734,7 +834,10 @@ protected Mono summarizing() { return notifyPreSummaryHook(messageList, generateOptions) .flatMap( preSummaryEvent -> { - List effectiveMessages = preSummaryEvent.getInputMessages(); + List effectiveMessages = + prependSystemMsg( + preSummaryEvent.getInputMessages(), + preSummaryEvent.getSystemMessage()); GenerateOptions effectiveOptions = preSummaryEvent.getEffectiveGenerateOptions(); @@ -775,7 +878,7 @@ private Mono streamAndAccumulateSummary( } private List prepareSummaryMessages() { - List messageList = prepareMessages(); + List messageList = new ArrayList<>(memory.getMessages()); messageList.add( Msg.builder() .name("user") @@ -816,20 +919,21 @@ private Mono handleSummaryError(Throwable error) { // ==================== Helper Methods ==================== /** - * Prepare messages for model input. + * Prepends the system message to {@code msgs} if non-null. + * + *

Called immediately before each {@code model.stream()} invocation to build the final + * LLM input without contaminating the in-memory message list. */ - private List prepareMessages() { - List messages = new ArrayList<>(); - if (sysPrompt != null && !sysPrompt.trim().isEmpty()) { - messages.add( - Msg.builder() - .name("system") - .role(MsgRole.SYSTEM) - .content(TextBlock.builder().text(sysPrompt).build()) - .build()); + private static List prependSystemMsg(List msgs, Msg systemMsg) { + if (systemMsg == null) { + return msgs != null ? msgs : List.of(); } - messages.addAll(memory.getMessages()); - return messages; + List result = new ArrayList<>(); + result.add(systemMsg); + if (msgs != null) { + result.addAll(msgs); + } + return result; } /** @@ -911,7 +1015,9 @@ private Mono notifyHooks(T event) { } private Mono notifyPreReasoningEvent(List msgs) { - return notifyHooks(new PreReasoningEvent(this, model.getModelName(), null, msgs)); + PreReasoningEvent event = new PreReasoningEvent(this, model.getModelName(), null, msgs); + event.setSystemMessage(currentSystemMsg.get()); + return notifyHooks(event); } private Mono notifyPostReasoning(Msg msg) { @@ -981,9 +1087,11 @@ private Mono notifyReasoningChunk(Msg chunkMsg, ReasoningContext context) private Mono notifyPreSummaryHook( List msgs, GenerateOptions generateOptions) { - return notifyHooks( + PreSummaryEvent event = new PreSummaryEvent( - this, model.getModelName(), generateOptions, msgs, maxIters, maxIters)); + this, model.getModelName(), generateOptions, msgs, maxIters, maxIters); + event.setSystemMessage(currentSystemMsg.get()); + return notifyHooks(event); } private Mono notifyPostSummaryHook(Msg msg, GenerateOptions generateOptions) { @@ -1219,6 +1327,7 @@ public Builder maxIters(int maxIters) { * @param hook The hook to add, must not be null * @return This builder instance for method chaining * @see Hook + * @see Hook#tools() */ public Builder hook(Hook hook) { this.hooks.add(hook); @@ -1234,6 +1343,7 @@ public Builder hook(Hook hook) { * @param hooks The list of hooks to add, must not be null * @return This builder instance for method chaining * @see Hook + * @see Hook#tools() */ public Builder hooks(List hooks) { this.hooks.addAll(hooks); @@ -1374,7 +1484,8 @@ public Builder planNotebook(PlanNotebook planNotebook) { *

The skill box is used to manage the skills for this agent. It will be used to register the skills to the toolkit. *

    *
  • Skill loader tools will be automatically registered to the toolkit
  • - *
  • A skill hook will be added to inject skill prompts and manage skill activation
  • + *
  • A skill hook will be added to inject skill prompts on {@link io.agentscope.core.hook.PreCallEvent} + * and manage skill activation
  • *
* @param skillBox The skill box to use for this agent * @return This builder instance for method chaining @@ -1562,6 +1673,8 @@ public ReActAgent build() { // Deep copy toolkit to avoid state interference between agents Toolkit agentToolkit = this.toolkit.copy(); + registerToolsFromHooks(agentToolkit); + if (enableMetaTool) { agentToolkit.registerMetaTool(); } @@ -1594,6 +1707,26 @@ public ReActAgent build() { return new ReActAgent(this, agentToolkit); } + /** + * Registers tool objects declared by hooks ({@link Hook#tools()}) on the agent toolkit. + * + *

Runs after {@link Toolkit#copy()} so hook-supplied tools are scoped to this agent + * instance without modifying the builder's original toolkit. + */ + private void registerToolsFromHooks(Toolkit agentToolkit) { + for (Hook hook : hooks) { + List toolObjects = hook.tools(); + if (toolObjects == null || toolObjects.isEmpty()) { + continue; + } + for (Object toolObject : toolObjects) { + if (toolObject != null) { + agentToolkit.registerTool(toolObject); + } + } + } + } + /** * Configures long-term memory based on the selected mode. * @@ -1748,7 +1881,9 @@ public Mono onEvent(T event) { *

This method automatically: *

    *
  • Registers skill load tool to the toolkit - *
  • Adds the skill hook to inject skill prompts and manage skill activation + *
  • Adds the skill hook to inject skill prompts on {@link io.agentscope.core.hook.PreCallEvent} + * (priority {@link io.agentscope.core.skill.SkillHook#SKILL_HOOK_PRIORITY}) and manage skill + * activation *
  • Uploads skill files to the upload directory if auto upload is enabled *
*/ 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 96935570c..dbd9988b2 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 @@ -20,9 +20,12 @@ import io.agentscope.core.hook.Hook; import io.agentscope.core.hook.PostCallEvent; import io.agentscope.core.hook.PreCallEvent; +import io.agentscope.core.hook.RuntimeContextAware; import io.agentscope.core.interruption.InterruptContext; import io.agentscope.core.interruption.InterruptSource; +import io.agentscope.core.memory.Memory; import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; import io.agentscope.core.shutdown.GracefulShutdownHook; import io.agentscope.core.shutdown.GracefulShutdownManager; import io.agentscope.core.state.StateModule; @@ -108,6 +111,10 @@ public abstract class AgentBase implements StateModule, Agent { private final AtomicReference interruptSource = new AtomicReference<>(InterruptSource.USER); + private final CopyOnWriteArrayList runtimeContextAwareHooks = + new CopyOnWriteArrayList<>(); + private final AtomicReference currentRuntimeContext = new AtomicReference<>(); + /** * Constructor for AgentBase. * @@ -143,6 +150,9 @@ public AgentBase(String name, String description, boolean checkRunning, List(hooks != null ? hooks : List.of()); this.hooks.addAll(systemHooks); sortHooks(); + for (Hook h : this.hooks) { + registerRuntimeContextHookIfNeeded(h); + } } @Override @@ -172,18 +182,20 @@ public final String getDescription() { public final Mono call(List msgs) { return Mono.using( this::acquireExecution, - resource -> - TracerRegistry.get() - .callAgent( - this, - msgs, - () -> - notifyPreCall(msgs) - .flatMap(this::doCall) - .flatMap(this::notifyPostCall) - .onErrorResume( - createErrorHandler( - msgs.toArray(new Msg[0])))), + resource -> { + beforeAgentExecution(msgs); + return TracerRegistry.get() + .callAgent( + this, + msgs, + () -> + notifyPreCall(msgs) + .flatMap(this::doCall) + .flatMap(this::notifyPostCall) + .onErrorResume( + createErrorHandler( + msgs.toArray(new Msg[0])))); + }, this::releaseExecution, true); } @@ -201,22 +213,20 @@ public final Mono call(List msgs) { public final Mono call(List msgs, Class structuredOutputClass) { return Mono.using( this::acquireExecution, - resource -> - TracerRegistry.get() - .callAgent( - this, - msgs, - () -> - notifyPreCall(msgs) - .flatMap( - m -> - doCall( - m, - structuredOutputClass)) - .flatMap(this::notifyPostCall) - .onErrorResume( - createErrorHandler( - msgs.toArray(new Msg[0])))), + resource -> { + beforeAgentExecution(msgs); + return TracerRegistry.get() + .callAgent( + this, + msgs, + () -> + notifyPreCall(msgs) + .flatMap(m -> doCall(m, structuredOutputClass)) + .flatMap(this::notifyPostCall) + .onErrorResume( + createErrorHandler( + msgs.toArray(new Msg[0])))); + }, this::releaseExecution, true); } @@ -234,18 +244,20 @@ public final Mono call(List msgs, Class structuredOutputClass) { public final Mono call(List msgs, JsonNode schema) { return Mono.using( this::acquireExecution, - resource -> - TracerRegistry.get() - .callAgent( - this, - msgs, - () -> - notifyPreCall(msgs) - .flatMap(m -> doCall(m, schema)) - .flatMap(this::notifyPostCall) - .onErrorResume( - createErrorHandler( - msgs.toArray(new Msg[0])))), + resource -> { + beforeAgentExecution(msgs); + return TracerRegistry.get() + .callAgent( + this, + msgs, + () -> + notifyPreCall(msgs) + .flatMap(m -> doCall(m, schema)) + .flatMap(this::notifyPostCall) + .onErrorResume( + createErrorHandler( + msgs.toArray(new Msg[0])))); + }, this::releaseExecution, true); } @@ -421,6 +433,7 @@ private AgentBase acquireExecution() { * @param resource the agent instance (ignored, uses {@code this}) */ private void releaseExecution(AgentBase resource) { + afterAgentExecution(); running.set(false); GracefulShutdownManager.getInstance().unregisterRequest(this); } @@ -500,6 +513,54 @@ protected Mono doObserve(Msg msg) { */ protected abstract Mono handleInterrupt(InterruptContext context, Msg... originalArgs); + /** + * Current per-call {@link RuntimeContext} when bound (e.g. by {@code ReActAgent} during a + * {@code call}). + */ + public RuntimeContext getRuntimeContext() { + return currentRuntimeContext.get(); + } + + /** + * Invoked at the start of a {@code call} / stream-backed call, after {@link + * #acquireExecution} and before any hooks. The default is a no-op. {@link + * io.agentscope.core.ReActAgent} uses this to bind a {@link RuntimeContext}. + */ + protected void beforeAgentExecution(List msgs) {} + + /** + * Invoked in {@code Mono.using} cleanup, before clearing the running state. Pairs with {@link + * #beforeAgentExecution(List)}. The default is a no-op. + */ + protected void afterAgentExecution() {} + + /** + * Binds {@code ctx} to the agent reference and all {@link RuntimeContextAware} hooks + * registered for this agent. + */ + protected void bindRuntimeContextToHooks(RuntimeContext ctx) { + currentRuntimeContext.set(ctx); + for (RuntimeContextAware h : runtimeContextAwareHooks) { + h.setRuntimeContext(ctx); + } + } + + /** + * Clears {@link #getRuntimeContext()} and nulls all {@link RuntimeContextAware} hooks. + */ + protected void unbindRuntimeContextFromHooks() { + for (RuntimeContextAware h : runtimeContextAwareHooks) { + h.setRuntimeContext(null); + } + currentRuntimeContext.set(null); + } + + private void registerRuntimeContextHookIfNeeded(Hook hook) { + if (hook instanceof RuntimeContextAware r && !runtimeContextAwareHooks.contains(r)) { + runtimeContextAwareHooks.add(r); + } + } + /** * Get the list of hooks for this agent. * Protected to allow subclasses to access hooks for custom notification logic. @@ -521,6 +582,7 @@ public List getHooks() { protected void addHook(Hook hook) { if (hook != null) { hooks.add(hook); + registerRuntimeContextHookIfNeeded(hook); sortHooks(); } } @@ -540,6 +602,9 @@ private void sortHooks() { protected void removeHook(Hook hook) { if (hook != null) { hooks.remove(hook); + if (hook instanceof RuntimeContextAware r) { + runtimeContextAwareHooks.remove(r); + } } } @@ -554,21 +619,104 @@ protected List getSortedHooks() { } /** - * Notify all hooks that agent is starting (preCall hook). + * Returns the initial system message to seed into {@link PreCallEvent} before hooks run. * - *

Hooks may modify the input messages via {@link PreCallEvent#setInputMessages(List)}. - * Hooks are executed sequentially, with each hook receiving the event modified by previous hooks. + *

The default implementation returns {@code null}. Subclasses (e.g. {@code ReActAgent}) + * override this to build a system message from their configured {@code sysPrompt}. * - * @param msgs Input messages - * @return Mono containing the messages after all hooks have processed them (may be modified) + * @return the seed system message, or {@code null} if none + */ + protected Msg seedSystemMsg() { + return null; + } + + /** + * Called after {@link PreCallEvent} hooks have run, with the final system message value. + * + *

The default implementation is a no-op. Subclasses (e.g. {@code ReActAgent}) override + * this to persist the system message into a per-call {@code AtomicReference} so it is + * available to subsequent events ({@code PreReasoningEvent}, {@code PreSummaryEvent}). + * + * @param systemMsg the system message produced by all PreCall hooks (may be null) */ - private Mono> notifyPreCall(List msgs) { - PreCallEvent event = new PreCallEvent(this, msgs); + protected void consumeSystemMsgAfterPreCall(Msg systemMsg) {} + + /** + * Notify all hooks that agent is starting (preCall hook). + * + *

The event's {@code inputMessages} contains the full message view: + * a snapshot of the agent's current memory followed by the {@code callArgs} passed to + * {@code call()}. Hooks may append non-SYSTEM messages to the tail. Injecting + * {@link MsgRole#SYSTEM} messages via {@code setInputMessages} is forbidden and + * detected at the end of this method — use {@link PreCallEvent#setSystemMessage} or + * {@link PreCallEvent#appendSystemContent} instead. + * + *

After hooks run the system message is handed off via + * {@link #consumeSystemMsgAfterPreCall(Msg)}, and only the tail (messages beyond the + * snapshot boundary) is returned for {@code doCall} to add to memory. + * + * @param callArgs messages passed by the caller to {@code call()} + * @return Mono containing the new tail messages that {@code doCall} should add to memory + */ + private Mono> notifyPreCall(List callArgs) { + // Take a memory snapshot before hooks run (pre-hook view) + List snapshot = List.of(); + if (this instanceof io.agentscope.core.ReActAgent reactAgent) { + Memory mem = reactAgent.getMemory(); + if (mem != null) { + snapshot = mem.getMessages(); + } + } + final int snapshotSize = snapshot.size(); + + // Build full input for hooks: snapshot + callArgs + List fullInput = new ArrayList<>(snapshot); + if (callArgs != null) { + fullInput.addAll(callArgs); + } + + PreCallEvent event = new PreCallEvent(this, fullInput); + event.setSystemMessage(seedSystemMsg()); + Mono result = Mono.just(event); for (Hook hook : getSortedHooks()) { result = result.flatMap(hook::onEvent); } - return result.map(PreCallEvent::getInputMessages); + + return result.map( + e -> { + // Hand off the system message to the per-call state + consumeSystemMsgAfterPreCall(e.getSystemMessage()); + + // Extract the tail: messages appended beyond the snapshot boundary + List currentInput = e.getInputMessages(); + List tail; + if (currentInput == null || currentInput.size() <= snapshotSize) { + tail = List.of(); + } else { + tail = + new ArrayList<>( + currentInput.subList(snapshotSize, currentInput.size())); + } + + // Guard (ReActAgent only): hooks must not inject SYSTEM messages into the + // tail, since the tail is persisted to memory and SYSTEM messages would + // accumulate. Agents without memory (e.g. UserAgent) may legitimately + // pass SYSTEM messages as call arguments. + if (AgentBase.this instanceof io.agentscope.core.ReActAgent) { + for (Msg msg : tail) { + if (msg != null && msg.getRole() == MsgRole.SYSTEM) { + throw new IllegalStateException( + "Hooks must not inject SYSTEM messages into" + + " PreCallEvent.inputMessages. Use" + + " event.setSystemMessage() or" + + " event.appendSystemContent() instead."); + } + } + } + + return tail; + }); } /** @@ -700,7 +848,7 @@ public final Mono observe(List msgs) { * @return Flux of events emitted during execution */ @Override - public final Flux stream(List msgs, StreamOptions options) { + public Flux stream(List msgs, StreamOptions options) { return createEventStream(options, () -> call(msgs)); } @@ -713,8 +861,7 @@ public final Flux stream(List msgs, StreamOptions options) { * @return Flux of events emitted during execution */ @Override - public final Flux stream( - List msgs, StreamOptions options, Class structuredModel) { + public Flux stream(List msgs, StreamOptions options, Class structuredModel) { return createEventStream(options, () -> call(msgs, structuredModel)); } @@ -727,7 +874,7 @@ public final Flux stream( * @return Flux of events emitted during execution */ @Override - public final Flux stream(List msgs, StreamOptions options, JsonNode schema) { + public Flux stream(List msgs, StreamOptions options, JsonNode schema) { return createEventStream(options, () -> call(msgs, schema)); } diff --git a/agentscope-core/src/main/java/io/agentscope/core/agent/RuntimeContext.java b/agentscope-core/src/main/java/io/agentscope/core/agent/RuntimeContext.java new file mode 100644 index 000000000..1d754815f --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/agent/RuntimeContext.java @@ -0,0 +1,360 @@ +/* + * 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.agent; + +import io.agentscope.core.session.Session; +import io.agentscope.core.state.SessionKey; +import io.agentscope.core.tool.ContextStore; +import io.agentscope.core.tool.ToolExecutionContext; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Per-call metadata for an agent run: session-scoped fields plus a thread-safe attribute bag and + * an optional {@link ToolExecutionContext} (tool-POJO / DI layer). + * + *

Attributes are not persisted. Hooks and tools may read and update the same instance for the + * duration of a single {@code call}. + */ +public class RuntimeContext { + + private static final String TYPED_DEFAULT_KEY = ""; + + private final String sessionId; + private final String userId; + private final Session session; + private final SessionKey sessionKey; + + /** String-keyed extras (legacy and generic extension). */ + private final ConcurrentMap stringAttributes; + + /** + * Typed layer: class -> (key -> value). For singleton-typed access, use {@link + * #TYPED_DEFAULT_KEY}. + */ + private final ConcurrentMap, ConcurrentMap> typedAttributes; + + private final ToolExecutionContext toolExecutionContext; + + private RuntimeContext(Builder builder) { + this.sessionId = builder.sessionId; + this.userId = builder.userId; + this.session = builder.session; + this.sessionKey = builder.sessionKey; + this.stringAttributes = new ConcurrentHashMap<>(); + this.typedAttributes = new ConcurrentHashMap<>(); + this.toolExecutionContext = builder.toolExecutionContext; + if (builder.stringExtras != null) { + this.stringAttributes.putAll(builder.stringExtras); + } + for (Map.Entry, Object> e : builder.typedSingletons.entrySet()) { + if (e.getValue() == null) { + continue; + } + @SuppressWarnings("unchecked") + Class type = (Class) e.getKey(); + putValue(TYPED_DEFAULT_KEY, type, e.getValue()); + } + } + + /** + * Shallow, mutable empty context (null session fields, empty attribute maps, no tool context). + */ + public static RuntimeContext empty() { + return new Builder().build(); + } + + public String getSessionId() { + return sessionId; + } + + public String getUserId() { + return userId; + } + + public Session getSession() { + return session; + } + + public SessionKey getSessionKey() { + return sessionKey; + } + + /** + * Returns the tool execution context provided at build time, if any. + * + *

Does not include runtime attribute projections; use {@link #asToolExecutionContext()}. + */ + public ToolExecutionContext getToolExecutionContext() { + return toolExecutionContext; + } + + @SuppressWarnings("unchecked") + public T get(String key) { + if (key == null) { + return null; + } + return (T) stringAttributes.get(key); + } + + public void put(String key, Object value) { + if (key == null) { + return; + } + if (value == null) { + stringAttributes.remove(key); + } else { + stringAttributes.put(key, value); + } + } + + @SuppressWarnings("unchecked") + public T get(Class type) { + if (type == null) { + return null; + } + T v = getValue(TYPED_DEFAULT_KEY, type); + if (v != null) { + return v; + } + // Allow accessing this RuntimeContext itself + if (type == RuntimeContext.class) { + return (T) this; + } + return null; + } + + public void put(Class type, T value) { + if (type == null) { + return; + } + if (value == null) { + removeTyped(type, TYPED_DEFAULT_KEY); + } else { + putValue(TYPED_DEFAULT_KEY, type, value); + } + } + + @SuppressWarnings("unchecked") + public T get(String key, Class type) { + if (key == null || type == null) { + return null; + } + T v = getValue(key, type); + if (v != null) { + return v; + } + if (TYPED_DEFAULT_KEY.equals(key) && type == RuntimeContext.class) { + return (T) this; + } + return null; + } + + public void put(String key, Class type, T value) { + if (key == null || type == null) { + return; + } + if (value == null) { + removeTyped(type, key); + } else { + putValue(key, type, value); + } + } + + /** + * View of string-keyed attributes; mutating the returned map affects this context. + * + *

Typed {@link #get(Class)} values are not included; use type-based accessors. + */ + public Map getExtra() { + return stringAttributes; + } + + private void putValue(String key, Class type, Object value) { + typedAttributes.computeIfAbsent(type, k -> new ConcurrentHashMap<>()).put(key, value); + } + + private T getValue(String key, Class type) { + Map m = typedAttributes.get(type); + if (m == null) { + return null; + } + Object o = m.get(key); + if (o == null) { + return null; + } + return type.isInstance(o) ? type.cast(o) : null; + } + + private void removeTyped(Class type, String key) { + Map m = typedAttributes.get(type); + if (m == null) { + return; + } + m.remove(key); + if (m.isEmpty()) { + typedAttributes.remove(type); + } + } + + /** + * Merges this context's data into a {@link ToolExecutionContext} for tool invocations. + * + *

Order: this instance is registered and exposed first (highest priority in {@link + * ToolExecutionContext#merge}, then a {@link ContextStore} for typed and string attributes, + * then stores from the nested {@link #getToolExecutionContext()} (if any). + */ + public ToolExecutionContext asToolExecutionContext() { + ToolExecutionContext.Builder b = ToolExecutionContext.builder(); + b.addStore(new DefaultMutableContextStore(this)); + if (toolExecutionContext != null) { + for (ContextStore s : toolExecutionContext.getStores()) { + if (s != null) { + b.addStore(s); + } + } + } + return b.build(); + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + private String sessionId; + private String userId; + private Session session; + private SessionKey sessionKey; + private Map stringExtras; + private final Map, Object> typedSingletons = new HashMap<>(); + private ToolExecutionContext toolExecutionContext; + + public Builder sessionId(String sessionId) { + this.sessionId = sessionId; + return this; + } + + public Builder userId(String userId) { + this.userId = userId; + return this; + } + + public Builder session(Session session) { + this.session = session; + return this; + } + + public Builder sessionKey(SessionKey sessionKey) { + this.sessionKey = sessionKey; + return this; + } + + public Builder put(String key, Object value) { + if (this.stringExtras == null) { + this.stringExtras = new ConcurrentHashMap<>(); + } + this.stringExtras.put(key, value); + return this; + } + + public Builder putAll(Map extras) { + if (extras == null || extras.isEmpty()) { + return this; + } + if (this.stringExtras == null) { + this.stringExtras = new ConcurrentHashMap<>(); + } + this.stringExtras.putAll(extras); + return this; + } + + public Builder put(Class type, T value) { + if (type != null) { + this.typedSingletons.put(type, value); + } + return this; + } + + /** + * Nests a {@link ToolExecutionContext} (e.g. agent builder-level tool DI) that will be + * visible at lower priority than runtime attributes in {@link #asToolExecutionContext()}. + */ + public Builder toolExecutionContext(ToolExecutionContext toolExecutionContext) { + this.toolExecutionContext = toolExecutionContext; + return this; + } + + public RuntimeContext build() { + return new RuntimeContext(this); + } + } + + /** + * Merged view of this {@link RuntimeContext} for the tool stack: first checks typed, then + * string map for legacy {@link #get(String)} keys, then defers to delegate stores. + */ + private static final class DefaultMutableContextStore implements ContextStore { + + private final RuntimeContext runtimeContext; + + private DefaultMutableContextStore(RuntimeContext runtimeContext) { + this.runtimeContext = runtimeContext; + } + + @Override + @SuppressWarnings("unchecked") + public T get(String key, Class type) { + T t = runtimeContext.getValue(key, type); + if (t != null) { + return t; + } + if (TYPED_DEFAULT_KEY.equals(key) && type == RuntimeContext.class) { + return (T) runtimeContext; + } + Object fromString = runtimeContext.stringAttributes.get(key); + if (type.isInstance(fromString)) { + return (T) fromString; + } + return null; + } + + @Override + @SuppressWarnings("unchecked") + public T get(Class type) { + T t = get(TYPED_DEFAULT_KEY, type); + if (t != null) { + return t; + } + if (type == RuntimeContext.class) { + return (T) runtimeContext; + } + return null; + } + + @Override + public boolean contains(String key, Class type) { + return get(key, type) != null; + } + + @Override + public boolean contains(Class type) { + return get(type) != null; + } + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/hook/Hook.java b/agentscope-core/src/main/java/io/agentscope/core/hook/Hook.java index 7cdaf939d..04e9c0dd1 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/hook/Hook.java +++ b/agentscope-core/src/main/java/io/agentscope/core/hook/Hook.java @@ -15,6 +15,11 @@ */ package io.agentscope.core.hook; +import io.agentscope.core.ReActAgent; +import io.agentscope.core.tool.AgentTool; +import io.agentscope.core.tool.Toolkit; +import java.util.Collections; +import java.util.List; import reactor.core.publisher.Mono; /** @@ -141,6 +146,24 @@ public interface Hook { */ Mono onEvent(T event); + /** + * Optional tools installed together with this hook. + * + *

During {@link ReActAgent.Builder#build()}, the framework copies the builder {@link + * Toolkit} and then registers each non-null element from every hook's {@code tools()} list on + * the agent-local copy using {@link Toolkit#registerTool(Object)}. + * + *

Return {@link AgentTool} instances and/or objects that declare {@code @Tool} methods. + * The default implementation returns an empty list so existing hooks need no change. + * + *

If this method returns {@code null}, it is treated as an empty list. + * + * @return tool instances to register for this hook (may be immutable) + */ + default List tools() { + return Collections.emptyList(); + } + /** * The priority of this hook (lower value = higher priority). * diff --git a/agentscope-core/src/main/java/io/agentscope/core/hook/HookEvent.java b/agentscope-core/src/main/java/io/agentscope/core/hook/HookEvent.java index c3f8c2974..eca862c1b 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/hook/HookEvent.java +++ b/agentscope-core/src/main/java/io/agentscope/core/hook/HookEvent.java @@ -18,6 +18,12 @@ import io.agentscope.core.ReActAgent; import io.agentscope.core.agent.Agent; import io.agentscope.core.memory.Memory; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; /** @@ -34,6 +40,31 @@ *
  • {@link #getTimestamp()} - When the event occurred
  • * * + *

    System message lifecycle: Every event carries a unified {@code systemMsg} field + * that holds the single {@link MsgRole#SYSTEM} message visible to the LLM. {@code ReActAgent} + * manages this field across the event lifecycle as follows: + *

      + *
    1. Seeded from {@code sysPrompt} at the start of each {@code call()} before + * {@link PreCallEvent} hooks run.
    2. + *
    3. After {@link PreCallEvent} hooks complete, the resulting system message is + * frozen as the base for the entire call.
    4. + *
    5. Before each {@link PreReasoningEvent} (and {@link PreSummaryEvent}), the frozen base + * is injected fresh into the event — hooks that run on these events always start from + * the same clean baseline and may append per-iteration content.
    6. + *
    7. Before {@code model.stream(...)} is called: the event's final system message is + * prepended to {@link PreReasoningEvent#getInputMessages()} as the first element.
    8. + *
    + * + *

    Because each {@link PreReasoningEvent} starts from a fresh copy of the frozen base, + * hooks that fire per-iteration (e.g. subagent guidance) can safely use + * {@link #appendSystemContent(String)} — content is added to that iteration's copy and never + * accumulates across iterations. + * + *

    Hooks should modify the system message exclusively via {@link #setSystemMessage(Msg)}, + * {@link #appendSystemContent(String)}, or {@link #appendSystemContent(ContentBlock)}. + * Injecting {@link MsgRole#SYSTEM} messages into {@code inputMessages} directly is forbidden + * and results in an {@link IllegalStateException} at runtime. + * *

    Modifiability: Whether an event allows modification is determined by * the presence of setter methods in the concrete event class. * @@ -47,6 +78,13 @@ public abstract sealed class HookEvent private final Agent agent; private final long timestamp; + /** + * The unified system message for this event. Hooks read and write this field via the + * helper methods below; {@code ReActAgent} propagates it between events and prepends it + * to the LLM input before every reasoning call. + */ + private Msg systemMsg; + /** * Constructor for HookEvent. * @@ -98,4 +136,70 @@ public final Memory getMemory() { } return null; } + + // ==================== System message API ==================== + + /** + * Returns the current unified system message, or {@code null} if none has been set. + * + *

    On {@link PreCallEvent} and {@link PreReasoningEvent}, modifications made by earlier + * hooks in the chain are already reflected here. + * + * @return the system message, may be null + */ + public final Msg getSystemMessage() { + return systemMsg; + } + + /** + * Replaces the entire system message with the given one. + * + *

    Prefer {@link #appendSystemContent} when you only need to add a portion of the system + * message; use this method only when you need to set a completely custom system message. + * + * @param systemMsg the new system message (may be null to clear) + */ + public final void setSystemMessage(Msg systemMsg) { + this.systemMsg = systemMsg; + } + + /** + * Appends the given text as a new {@link TextBlock} at the end of the system message. + * + *

    If no system message exists yet, one is created automatically with + * {@link MsgRole#SYSTEM} and name {@code "system"}. + * + * @param text the text to append (must not be null) + */ + public final void appendSystemContent(String text) { + Objects.requireNonNull(text, "text cannot be null"); + appendSystemContent(TextBlock.builder().text(text).build()); + } + + /** + * Appends a {@link ContentBlock} at the end of the system message. + * + *

    If no system message exists yet, one is created automatically with + * {@link MsgRole#SYSTEM} and name {@code "system"}. + * + * @param block the content block to append (must not be null) + */ + public final void appendSystemContent(ContentBlock block) { + Objects.requireNonNull(block, "block cannot be null"); + if (systemMsg == null) { + systemMsg = Msg.builder().name("system").role(MsgRole.SYSTEM).content(block).build(); + } else { + List merged = new ArrayList<>(systemMsg.getContent()); + merged.add(block); + systemMsg = + Msg.builder() + .id(systemMsg.getId()) + .name(systemMsg.getName()) + .role(MsgRole.SYSTEM) + .content(merged) + .metadata(systemMsg.getMetadata()) + .timestamp(systemMsg.getTimestamp()) + .build(); + } + } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/hook/RuntimeContextAware.java b/agentscope-core/src/main/java/io/agentscope/core/hook/RuntimeContextAware.java new file mode 100644 index 000000000..e110f3f9d --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/hook/RuntimeContextAware.java @@ -0,0 +1,39 @@ +/* + * 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.hook; + +import io.agentscope.core.agent.RuntimeContext; + +/** + * Optional contract for {@link Hook} implementations that need the current + * per-call {@link RuntimeContext}. + * + *

    During a {@code ReActAgent.call(msgs, ctx)} execution, the framework sets the context + * on all registered hooks that implement this interface, and clears it on completion. Hooks may + * cache the reference in a field, as the same {@link RuntimeContext} instance is mutably shared + * for cross-hook/tool coordination. + */ +@FunctionalInterface +public interface RuntimeContextAware { + + /** + * Injects the runtime context for the current call, or {@code null} when not executing or + * when clearing after a call. + * + * @param context current runtime context, or null + */ + void setRuntimeContext(RuntimeContext context); +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/model/ModelRegistry.java b/agentscope-core/src/main/java/io/agentscope/core/model/ModelRegistry.java new file mode 100644 index 000000000..101889b22 --- /dev/null +++ b/agentscope-core/src/main/java/io/agentscope/core/model/ModelRegistry.java @@ -0,0 +1,259 @@ +/* + * 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.model; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.regex.Pattern; + +/** + * Registry for resolving {@link Model} instances from string identifiers (named instances or + * {@code provider:model} patterns). User-registered factories take precedence over built-in + * providers. + * + *

    Built-in providers read API keys from standard environment variables when auto-creating + * models: {@code OPENAI_API_KEY}, {@code DASHSCOPE_API_KEY}, {@code GEMINI_API_KEY}, {@code + * ANTHROPIC_API_KEY} (optional for Anthropic SDK), {@code OLLAMA_BASE_URL} (optional, defaults to + * {@code http://localhost:11434}). + */ +public final class ModelRegistry { + + private static final ConcurrentHashMap namedModels = new ConcurrentHashMap<>(); + private static final CopyOnWriteArrayList userFactories = + new CopyOnWriteArrayList<>(); + private static final List builtinFactories = new ArrayList<>(); + private static final ConcurrentHashMap resolvedCache = new ConcurrentHashMap<>(); + + static { + registerBuiltin( + "openai:(.+)", + modelId -> { + String modelName = modelId.substring("openai:".length()); + String apiKey = env("OPENAI_API_KEY"); + if (apiKey == null || apiKey.isBlank()) { + throw new IllegalStateException( + "Environment variable OPENAI_API_KEY is required to auto-create" + + " model: " + + modelId); + } + return OpenAIChatModel.builder().apiKey(apiKey).modelName(modelName).stream( + true) + .build(); + }); + registerBuiltin( + "dashscope:(.+)", + modelId -> { + String modelName = modelId.substring("dashscope:".length()); + String apiKey = requireApiKey("DASHSCOPE_API_KEY", modelId); + return DashScopeChatModel.builder().apiKey(apiKey).modelName(modelName).stream( + true) + .build(); + }); + registerBuiltin( + "qwen-.+", + modelId -> { + String apiKey = requireApiKey("DASHSCOPE_API_KEY", modelId); + return DashScopeChatModel.builder().apiKey(apiKey).modelName(modelId).stream( + true) + .build(); + }); + registerBuiltin( + "anthropic:(.+)", + modelId -> { + String modelName = modelId.substring("anthropic:".length()); + String apiKey = env("ANTHROPIC_API_KEY"); + return AnthropicChatModel.builder().apiKey(apiKey).modelName(modelName).stream( + true) + .build(); + }); + registerBuiltin( + "gemini:(.+)", + modelId -> { + String modelName = modelId.substring("gemini:".length()); + String apiKey = requireApiKey("GEMINI_API_KEY", modelId); + return GeminiChatModel.builder() + .apiKey(apiKey) + .modelName(modelName) + .streamEnabled(true) + .build(); + }); + registerBuiltin( + "ollama:(.+)", + modelId -> { + String modelName = modelId.substring("ollama:".length()); + String baseUrl = env("OLLAMA_BASE_URL"); + if (baseUrl == null || baseUrl.isBlank()) { + baseUrl = "http://localhost:11434"; + } + return OllamaChatModel.builder().modelName(modelName).baseUrl(baseUrl).build(); + }); + } + + private ModelRegistry() {} + + /** + * Registers a named {@link Model} instance. {@link #resolve(String)} returns this instance for + * an exact {@code name} match (no caching of factory-created instances applies). + */ + public static void register(String name, Model model) { + Objects.requireNonNull(name, "name"); + Objects.requireNonNull(model, "model"); + namedModels.put(name, model); + } + + /** + * Registers a factory matched against the full {@code modelId} string using {@link + * Pattern#matches}. Newly registered factories are consulted before older user registrations + * and before built-in providers. + * + * @param modelNameRegex regex with semantics of Pattern#matches(CharSequence) on the + * full model id + * @param factory creates a {@link Model} from the full model id + */ + public static void registerFactory(String modelNameRegex, ModelFactory factory) { + Objects.requireNonNull(modelNameRegex, "modelNameRegex"); + Objects.requireNonNull(factory, "factory"); + Pattern pattern = Pattern.compile(modelNameRegex); + userFactories.add(0, new ProviderEntry(pattern, factory)); + } + + /** + * Resolves a {@link Model} for the given id: named registration first, then cached + * factory-created instance, then user factories (newest first), then built-in factories. + * + * @throws IllegalArgumentException if the id cannot be resolved or creation fails + */ + public static Model resolve(String modelId) { + Objects.requireNonNull(modelId, "modelId"); + String trimmed = modelId.trim(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("modelId must not be blank"); + } + + Model named = namedModels.get(trimmed); + if (named != null) { + return named; + } + + Model cached = resolvedCache.get(trimmed); + if (cached != null) { + return cached; + } + + ProviderEntry entry = findMatchingEntry(trimmed); + if (entry == null) { + throw new IllegalArgumentException(buildNotFoundMessage(trimmed)); + } + + try { + Model created = entry.factory().create(trimmed); + Objects.requireNonNull(created, "ModelFactory returned null for: " + trimmed); + resolvedCache.put(trimmed, created); + return created; + } catch (RuntimeException e) { + throw new IllegalArgumentException( + "Failed to create model for id: " + trimmed + ": " + e.getMessage(), e); + } + } + + /** + * Returns {@code true} if {@link #resolve(String)} can find a named model or a matching factory + * pattern (without creating an instance). + */ + public static boolean canResolve(String modelId) { + if (modelId == null) { + return false; + } + String trimmed = modelId.trim(); + if (trimmed.isEmpty()) { + return false; + } + if (namedModels.containsKey(trimmed)) { + return true; + } + return findMatchingEntry(trimmed) != null; + } + + /** + * Clears named models, user-registered factories, and the factory-resolution cache. Built-in + * provider rules are preserved. Intended for tests. + */ + public static void reset() { + namedModels.clear(); + userFactories.clear(); + resolvedCache.clear(); + } + + @FunctionalInterface + public interface ModelFactory { + Model create(String modelId); + } + + private record ProviderEntry(Pattern pattern, ModelFactory factory) {} + + private static void registerBuiltin(String regex, ModelFactory factory) { + builtinFactories.add(new ProviderEntry(Pattern.compile(regex), factory)); + } + + private static ProviderEntry findMatchingEntry(String modelId) { + for (ProviderEntry e : userFactories) { + if (e.pattern().matcher(modelId).matches()) { + return e; + } + } + for (ProviderEntry e : builtinFactories) { + if (e.pattern().matcher(modelId).matches()) { + return e; + } + } + return null; + } + + private static String env(String key) { + return System.getenv(key); + } + + private static String requireApiKey(String envKey, String modelId) { + String v = env(envKey); + if (v == null || v.isBlank()) { + throw new IllegalStateException( + "Environment variable " + + envKey + + " is required to auto-create model: " + + modelId); + } + return v; + } + + private static String buildNotFoundMessage(String modelId) { + return "Cannot resolve model: \"" + + modelId + + "\".\n\nPossible causes:\n" + + " - No named model registered with this name. Use ModelRegistry.register(\"" + + modelId + + "\", instance).\n" + + " - No matching provider factory. Built-in providers: openai, dashscope," + + " gemini, anthropic, ollama.\n" + + " Format: \":\", e.g. \"openai:gpt-5.5\"," + + " \"dashscope:qwen-max\".\n" + + " - DashScope short form: \"qwen-*\" model ids (requires DASHSCOPE_API_KEY).\n" + + " - Missing API key environment variable (e.g., OPENAI_API_KEY," + + " DASHSCOPE_API_KEY)."; + } +} diff --git a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillHook.java b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillHook.java index e548c1c1a..b5dbf7b07 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/skill/SkillHook.java +++ b/agentscope-core/src/main/java/io/agentscope/core/skill/SkillHook.java @@ -17,16 +17,28 @@ import io.agentscope.core.hook.Hook; import io.agentscope.core.hook.HookEvent; -import io.agentscope.core.hook.PreReasoningEvent; -import io.agentscope.core.message.ContentBlock; -import io.agentscope.core.message.Msg; -import io.agentscope.core.message.MsgRole; -import io.agentscope.core.message.TextBlock; -import java.util.ArrayList; -import java.util.List; +import io.agentscope.core.hook.PreCallEvent; import reactor.core.publisher.Mono; +/** + * Injects the skill catalog prompt into the unified system message on {@link PreCallEvent} via + * {@link PreCallEvent#appendSystemContent(String)}. + * + *

    Uses priority {@link #SKILL_HOOK_PRIORITY} so that, in typical {@code HarnessAgent} wiring, + * this hook runs after {@code SubagentsHook} (80) and before {@code WorkspaceContextHook} (900), + * yielding append order: base prompt → subagents → skills → workspace context. + * + *

    The skill prompt is appended to the transient system message and is never stored in + * the agent's persistent {@code Memory}. + */ public class SkillHook implements Hook { + + /** + * Runs after subagent prompt injection and before workspace context injection in the default + * harness hook chain. + */ + public static final int SKILL_HOOK_PRIORITY = 85; + private final SkillBox skillBox; public SkillHook(SkillBox skillBox) { @@ -35,60 +47,17 @@ public SkillHook(SkillBox skillBox) { @Override public Mono onEvent(T event) { - // Inject skill prompts - if (event instanceof PreReasoningEvent preReasoningEvent) { + if (event instanceof PreCallEvent preCallEvent) { String skillPrompt = skillBox.getSkillPrompt(); if (skillPrompt != null && !skillPrompt.isEmpty()) { - List inputMessages = preReasoningEvent.getInputMessages(); - int systemIndex = findFirstSystemMessageIndex(inputMessages); - if (systemIndex >= 0) { - // Merge skill prompt into existing system message in-place (structural) - Msg existingSystem = inputMessages.get(systemIndex); - List mergedContent = new ArrayList<>(existingSystem.getContent()); - mergedContent.add(TextBlock.builder().text(skillPrompt).build()); - Msg mergedMsg = - Msg.builder() - .id(existingSystem.getId()) - .role(MsgRole.SYSTEM) - .name(existingSystem.getName()) - .content(mergedContent) - .metadata(existingSystem.getMetadata()) - .timestamp(existingSystem.getTimestamp()) - .build(); - List newMessages = new ArrayList<>(inputMessages); - newMessages.set(systemIndex, mergedMsg); - preReasoningEvent.setInputMessages(newMessages); - } else { - // No existing system message, add one at the beginning - List newMessages = new ArrayList<>(inputMessages.size() + 1); - newMessages.add( - Msg.builder() - .role(MsgRole.SYSTEM) - .content(TextBlock.builder().text(skillPrompt).build()) - .build()); - newMessages.addAll(inputMessages); - preReasoningEvent.setInputMessages(newMessages); - } + preCallEvent.appendSystemContent(skillPrompt); } - return Mono.just(event); } - return Mono.just(event); } - private int findFirstSystemMessageIndex(List messages) { - for (int i = 0; i < messages.size(); i++) { - if (messages.get(i).getRole() == MsgRole.SYSTEM) { - return i; - } - } - return -1; - } - @Override public int priority() { - // High priority (55) to ensure skills system prompt is added early - // before other hooks that might depend on skill system prompt - return 55; + return SKILL_HOOK_PRIORITY; } } diff --git a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java index 5da99f2d6..ea6634ced 100644 --- a/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java +++ b/agentscope-core/src/main/java/io/agentscope/core/tool/ToolMethodInvoker.java @@ -16,6 +16,7 @@ package io.agentscope.core.tool; import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.ToolResultBlock; @@ -168,6 +169,10 @@ else if (param.getType() == Agent.class) { else if (param.getType() == ToolExecutionContext.class) { args[i] = context; } + // Per-call agent runtime (when merged into the execution context) + else if (param.getType() == RuntimeContext.class) { + args[i] = context != null ? context.get(RuntimeContext.class) : null; + } // User-defined POJO: try to resolve from context else if (isUserContextPojo(param)) { args[i] = resolveContextParameter(param, context); diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentRuntimeContextTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentRuntimeContextTest.java new file mode 100644 index 000000000..282eec790 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/ReActAgentRuntimeContextTest.java @@ -0,0 +1,213 @@ +/* + * 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.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.test.MockModel; +import io.agentscope.core.agent.test.TestConstants; +import io.agentscope.core.agent.test.TestUtils; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PreReasoningEvent; +import io.agentscope.core.hook.RuntimeContextAware; +import io.agentscope.core.memory.InMemoryMemory; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.ChatUsage; +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.core.util.JsonUtils; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +@DisplayName("ReActAgent RuntimeContext") +class ReActAgentRuntimeContextTest { + + private static final class SharedPojo { + final String value; + + SharedPojo(String value) { + this.value = value; + } + } + + private static class CtxTools { + @Tool(description = "Read RuntimeContext in a tool call") + public String ctx_probe( + RuntimeContext ctx, @ToolParam(name = "q", description = "q") String q) { + SharedPojo p = ctx.get(SharedPojo.class); + return ctx.getUserId() + "|" + (p != null ? p.value : "null") + "|" + q; + } + } + + private InMemoryMemory memory; + private Toolkit toolkit; + + @BeforeEach + void setUp() { + memory = new InMemoryMemory(); + toolkit = new Toolkit(); + toolkit.registerTool(new CtxTools()); + } + + @Test + @DisplayName("RuntimeContextAware + tools see the same per-call context") + void awareHookAndToolContext() { + AtomicReference fromSetter = new AtomicReference<>(); + final int[] modelRound = {0}; + + Hook hook = new CtxHook(fromSetter); + MockModel model = + new MockModel( + messages -> { + if (modelRound[0]++ == 0) { + return List.of( + createToolResponse( + "ctx_probe", "c1", Map.of("q", "tool-q"))); + } + return List.of( + ChatResponse.builder() + .content( + List.of( + TextBlock.builder() + .text("final") + .build())) + .usage(new ChatUsage(1, 1, 0)) + .build()); + }); + + ReActAgent agent = + ReActAgent.builder() + .name(TestConstants.TEST_REACT_AGENT_NAME) + .sysPrompt(TestConstants.DEFAULT_SYS_PROMPT) + .model(model) + .toolkit(toolkit) + .memory(memory) + .hooks(List.of(hook)) + .build(); + + RuntimeContext run = + RuntimeContext.builder() + .userId("per-call-uid") + .put(SharedPojo.class, new SharedPojo("from-initial-put")) + .build(); + + Msg user = TestUtils.createUserMessage("User", "use ctx_probe"); + Msg out = + agent.call(List.of(user), run) + .block(Duration.ofMillis(TestConstants.DEFAULT_TEST_TIMEOUT_MS)); + + assertNotNull(out); + String toolOut = lastToolText(agent); + assertTrue( + toolOut.contains("per-call-uid|from-pre|tool-q"), + "unexpected tool output: " + toolOut); + + RuntimeContext r = fromSetter.get(); + assertNull(r, "unbind should clear setRuntimeContext(null)"); + + assertTrue( + memory.getMessages().stream() + .anyMatch(m -> m.hasContentBlocks(ToolResultBlock.class))); + } + + private static String lastToolText(ReActAgent agent) { + List list = new ArrayList<>(agent.getMemory().getMessages()); + Collections.reverse(list); + for (Msg m : list) { + if (m.getContent() == null) { + continue; + } + for (ContentBlock c : m.getContent()) { + if (c instanceof ToolResultBlock tr) { + for (ContentBlock o : tr.getOutput()) { + if (o instanceof TextBlock tb) { + return tb.getText(); + } + } + } + } + } + return ""; + } + + private static ChatResponse createToolResponse( + String name, String id, Map input) { + return ChatResponse.builder() + .content( + List.of( + ToolUseBlock.builder() + .name(name) + .id(id) + .input(input) + .content(JsonUtils.getJsonCodec().toJson(input)) + .build())) + .usage(new ChatUsage(1, 1, 0)) + .build(); + } + + private static final class CtxHook implements Hook, RuntimeContextAware { + private final AtomicReference fromSetter; + private final AtomicInteger preCount = new AtomicInteger(); + + CtxHook(AtomicReference fromSetter) { + this.fromSetter = fromSetter; + } + + @Override + public void setRuntimeContext(RuntimeContext ctx) { + fromSetter.set(ctx); + } + + @Override + public Mono onEvent(T event) { + if (event instanceof PreReasoningEvent) { + return Mono.defer( + () -> { + if (preCount.getAndIncrement() == 0) { + AgentBase a = (AgentBase) ((PreReasoningEvent) event).getAgent(); + RuntimeContext rc = a.getRuntimeContext(); + assertNotNull(rc); + assertEquals("per-call-uid", rc.getUserId()); + assertEquals("from-initial-put", rc.get(SharedPojo.class).value); + rc.put(SharedPojo.class, new SharedPojo("from-pre")); + } + return Mono.just(event); + }); + } + return Mono.just(event); + } + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/agent/RuntimeContextTest.java b/agentscope-core/src/test/java/io/agentscope/core/agent/RuntimeContextTest.java new file mode 100644 index 000000000..89e338173 --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/agent/RuntimeContextTest.java @@ -0,0 +1,146 @@ +/* + * 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.agent; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; + +import io.agentscope.core.state.SimpleSessionKey; +import io.agentscope.core.tool.ToolExecutionContext; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("RuntimeContext") +class RuntimeContextTest { + + private static final class PojoA { + final String id; + + PojoA(String id) { + this.id = id; + } + } + + private static final class PojoB { + final int n; + + PojoB(int n) { + this.n = n; + } + } + + @Test + @DisplayName("empty() builds an empty, mutable context") + void empty_isMutable() { + RuntimeContext ctx = RuntimeContext.empty(); + assertNull(ctx.getSessionId()); + assertNull(ctx.getUserId()); + assertNull(ctx.getSessionKey()); + ctx.put("k", "v"); + assertEquals("v", ctx.get("k")); + } + + @Test + @DisplayName("builder sets session fields and string extras") + void builderSessionAndStringExtras() { + RuntimeContext ctx = + RuntimeContext.builder() + .sessionId("sid-1") + .userId("u-1") + .sessionKey(SimpleSessionKey.of("sk")) + .put("extra", 42) + .build(); + assertEquals("sid-1", ctx.getSessionId()); + assertEquals("u-1", ctx.getUserId()); + assertNotNull(ctx.getSessionKey()); + assertEquals(Integer.valueOf(42), ctx.get("extra")); + } + + @Test + @DisplayName("typed get/put and keyed typed access") + void typedAccess() { + PojoA a = new PojoA("a"); + RuntimeContext ctx = RuntimeContext.builder().put(PojoA.class, a).build(); + assertSame(a, ctx.get(PojoA.class)); + PojoB b0 = new PojoB(1); + PojoB b1 = new PojoB(2); + ctx.put("one", PojoB.class, b0); + ctx.put("two", PojoB.class, b1); + assertSame(b0, ctx.get("one", PojoB.class)); + assertSame(b1, ctx.get("two", PojoB.class)); + ctx.put(PojoA.class, null); + assertNull(ctx.get(PojoA.class)); + } + + @Test + @DisplayName("get(Class) for RuntimeContext returns the instance") + void selfTypedAccess() { + RuntimeContext ctx = RuntimeContext.empty(); + assertSame(ctx, ctx.get(RuntimeContext.class)); + assertSame(ctx, ctx.get("", RuntimeContext.class)); + } + + @Test + @DisplayName("asToolExecutionContext exposes typed values with higher priority than agent TEC") + void asToolExecutionContextMergePriority() { + PojoA fromRun = new PojoA("from-run"); + PojoA fromAgent = new PojoA("from-agent"); + RuntimeContext run = RuntimeContext.builder().put(PojoA.class, fromRun).build(); + ToolExecutionContext agent = + ToolExecutionContext.builder().register(PojoA.class, fromAgent).build(); + ToolExecutionContext merged = + ToolExecutionContext.merge(run.asToolExecutionContext(), agent); + assertSame(fromRun, merged.get(PojoA.class)); + } + + @Test + @DisplayName("concurrent puts on distinct keys from multiple threads") + void threadSafety() throws Exception { + RuntimeContext ctx = RuntimeContext.empty(); + int threads = 8; + int per = 200; + CyclicBarrier b = new CyclicBarrier(threads); + AtomicInteger ok = new AtomicInteger(); + Thread[] t = new Thread[threads]; + for (int i = 0; i < threads; i++) { + final int fi = i; + t[i] = + new Thread( + () -> { + try { + b.await(); + for (int j = 0; j < per; j++) { + String k = "k-" + fi + "-" + j; + ctx.put(k, k); + assertEquals(k, ctx.get(k)); + } + ok.incrementAndGet(); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + t[i].start(); + } + for (Thread th : t) { + th.join(); + } + assertEquals(threads, ok.get()); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/hook/HookEventTest.java b/agentscope-core/src/test/java/io/agentscope/core/hook/HookEventTest.java index 958023215..e40378640 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/hook/HookEventTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/hook/HookEventTest.java @@ -23,6 +23,7 @@ import io.agentscope.core.agent.Agent; import io.agentscope.core.agent.AgentBase; import io.agentscope.core.interruption.InterruptContext; +import io.agentscope.core.message.ContentBlock; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; @@ -358,4 +359,80 @@ void testNullValidation() { assertThrows(NullPointerException.class, () -> new ErrorEvent(testAgent, null)); } } + + @Nested + @DisplayName("System message API Tests") + class SystemMessageApiTests { + + private PreCallEvent event; + + @org.junit.jupiter.api.BeforeEach + void setup() { + event = new PreCallEvent(testAgent, List.of()); + } + + @Test + @DisplayName("getSystemMessage returns null by default") + void getSystemMessage_nullByDefault() { + assertNull(event.getSystemMessage()); + } + + @Test + @DisplayName("setSystemMessage stores and retrieves value") + void setSystemMessage_storesValue() { + Msg sys = + Msg.builder() + .role(MsgRole.SYSTEM) + .content(TextBlock.builder().text("hello").build()) + .build(); + event.setSystemMessage(sys); + assertEquals(sys, event.getSystemMessage()); + } + + @Test + @DisplayName("setSystemMessage(null) clears value") + void setSystemMessage_null_clears() { + event.appendSystemContent("init"); + event.setSystemMessage(null); + assertNull(event.getSystemMessage()); + } + + @Test + @DisplayName("appendSystemContent(String) creates SYSTEM msg when null") + void appendSystemContent_string_createsWhenNull() { + event.appendSystemContent("Hello system"); + + Msg sys = event.getSystemMessage(); + assertNotNull(sys); + assertEquals(MsgRole.SYSTEM, sys.getRole()); + assertEquals("Hello system", sys.getTextContent()); + } + + @Test + @DisplayName("appendSystemContent(String) appends to existing message") + void appendSystemContent_string_appendsToExisting() { + event.appendSystemContent("Part 1"); + event.appendSystemContent("Part 2"); + + Msg sys = event.getSystemMessage(); + assertNotNull(sys); + assertEquals(2, sys.getContent().size()); + assertEquals("Part 1\nPart 2", sys.getTextContent()); + } + + @Test + @DisplayName("appendSystemContent(ContentBlock) rejects null") + void appendSystemContent_block_rejectsNull() { + assertThrows( + NullPointerException.class, + () -> event.appendSystemContent((ContentBlock) null)); + } + + @Test + @DisplayName("appendSystemContent(String) rejects null") + void appendSystemContent_string_rejectsNull() { + assertThrows( + NullPointerException.class, () -> event.appendSystemContent((String) null)); + } + } } diff --git a/agentscope-core/src/test/java/io/agentscope/core/hook/HookToolsRegistrationTest.java b/agentscope-core/src/test/java/io/agentscope/core/hook/HookToolsRegistrationTest.java new file mode 100644 index 000000000..691dd2b9e --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/hook/HookToolsRegistrationTest.java @@ -0,0 +1,156 @@ +/* + * 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 + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.agentscope.core.hook; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.test.MockModel; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.tool.AgentTool; +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolCallParam; +import io.agentscope.core.tool.Toolkit; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Mono; + +/** Tests {@link Hook#tools()} registration during {@link ReActAgent.Builder#build()}. */ +@DisplayName("Hook bundled tools registration") +class HookToolsRegistrationTest { + + private final MockModel model = new MockModel("ok"); + + @Test + @DisplayName("build() registers AgentTool instances from Hook.tools() on agent toolkit") + void registersAgentToolsFromHook() { + AgentTool ping = + new AgentTool() { + @Override + public String getName() { + return "hook_ping"; + } + + @Override + public String getDescription() { + return "ping"; + } + + @Override + public Map getParameters() { + return Map.of( + "type", "object", + "properties", Map.of(), + "required", Collections.emptyList()); + } + + @Override + public Mono callAsync(ToolCallParam param) { + return Mono.just(ToolResultBlock.text("ok")); + } + }; + + Hook hook = + new Hook() { + @Override + public Mono onEvent(T event) { + return Mono.just(event); + } + + @Override + public List tools() { + return List.of(ping); + } + }; + + Toolkit builderToolkit = new Toolkit(); + ReActAgent agent = + ReActAgent.builder() + .name("a") + .model(model) + .toolkit(builderToolkit) + .hook(hook) + .build(); + + assertNotNull(agent.getToolkit().getTool("hook_ping")); + assertFalse(builderToolkit.getToolNames().contains("hook_ping")); + } + + @Test + @DisplayName("build() registers @Tool POJOs returned by Hook.tools()") + void registersMethodToolsFromHook() { + class Pojo { + @Tool(name = "hook_add") + public int add(int a, int b) { + return a + b; + } + } + + Hook hook = + new Hook() { + @Override + public Mono onEvent(T event) { + return Mono.just(event); + } + + @Override + public List tools() { + return List.of(new Pojo()); + } + }; + + Toolkit builderToolkit = new Toolkit(); + ReActAgent agent = + ReActAgent.builder() + .name("a") + .model(model) + .toolkit(builderToolkit) + .hook(hook) + .build(); + + assertTrue(agent.getToolkit().getToolNames().contains("hook_add")); + } + + @Test + @DisplayName("Hook.tools() returning null is treated as empty") + void nullToolsListIgnored() { + Hook hook = + new Hook() { + @Override + public Mono onEvent(T event) { + return Mono.just(event); + } + + @Override + public List tools() { + return null; + } + }; + + assertDoesNotThrow( + () -> { + ReActAgent agent = + ReActAgent.builder().name("a").model(model).hook(hook).build(); + assertNotNull(agent.getToolkit()); + }); + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/hook/ReActAgentSystemMsgTest.java b/agentscope-core/src/test/java/io/agentscope/core/hook/ReActAgentSystemMsgTest.java new file mode 100644 index 000000000..9bdec58be --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/hook/ReActAgentSystemMsgTest.java @@ -0,0 +1,330 @@ +/* + * 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.hook; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.memory.InMemoryMemory; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.Model; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * Tests for the unified system-message propagation introduced by the HookEvent.systemMsg + * refactoring. + * + *

    Key scenarios verified: + *

      + *
    • sysPrompt is seeded as systemMsg before PreCallEvent hooks run
    • + *
    • systemMsg modified by a PreCallEvent hook is propagated to PreReasoningEvent
    • + *
    • systemMsg is prepended to the model.stream() input as the first message
    • + *
    • PreCallEvent.inputMessages now contains memory snapshot + callArgs (full view)
    • + *
    • Hooks that inject SYSTEM into inputMessages.tail throw IllegalStateException
    • + *
    + */ +@DisplayName("ReActAgent system-message propagation tests") +class ReActAgentSystemMsgTest { + + private static final Duration TIMEOUT = Duration.ofSeconds(5); + + private Model mockModel; + private InMemoryMemory memory; + + @BeforeEach + void setUp() { + mockModel = mock(Model.class); + memory = new InMemoryMemory(); + } + + /** Stubs the model to emit a single text response. */ + private void stubModelText(String text) { + ChatResponse response = + ChatResponse.builder() + .content(List.of(TextBlock.builder().text(text).build())) + .build(); + when(mockModel.stream(anyList(), any(), any())).thenReturn(Flux.just(response)); + when(mockModel.getModelName()).thenReturn("stub"); + } + + private Msg userMsg(String text) { + return Msg.builder() + .name("user") + .role(MsgRole.USER) + .content(TextBlock.builder().text(text).build()) + .build(); + } + + // ========================================================================= + + @Nested + @DisplayName("sysPrompt seeded as systemMsg") + class SysPromptSeeding { + + @Test + @DisplayName("sysPrompt becomes systemMsg before PreCallEvent hooks run") + void sysPrompt_isSeededIntoSystemMsg() { + AtomicReference capturedSysMsg = new AtomicReference<>(); + + stubModelText("done"); + ReActAgent agent = + ReActAgent.builder() + .name("agent") + .model(mockModel) + .memory(memory) + .sysPrompt("You are a helpful assistant.") + .maxIters(1) + .hook( + new Hook() { + @Override + public Mono onEvent(T event) { + if (event instanceof PreCallEvent) { + capturedSysMsg.set(event.getSystemMessage()); + } + return Mono.just(event); + } + }) + .build(); + + agent.call(List.of(userMsg("hello"))).block(TIMEOUT); + + Msg sys = capturedSysMsg.get(); + assertNotNull(sys); + assertEquals(MsgRole.SYSTEM, sys.getRole()); + assertTrue( + sys.getTextContent().contains("You are a helpful assistant."), + "systemMsg should contain sysPrompt text"); + } + + @Test + @DisplayName("agent with empty sysPrompt seeds null systemMsg") + void emptySysPrompt_seedsNullSystemMsg() { + AtomicReference capturedSysMsg = + new AtomicReference<>( + Msg.builder() + .role(MsgRole.SYSTEM) + .content(TextBlock.builder().text("marker").build()) + .build()); + + stubModelText("done"); + ReActAgent agent = + ReActAgent.builder() + .name("agent") + .model(mockModel) + .memory(memory) + .maxIters(1) + .hook( + new Hook() { + @Override + public Mono onEvent(T event) { + if (event instanceof PreCallEvent) { + capturedSysMsg.set(event.getSystemMessage()); + } + return Mono.just(event); + } + }) + .build(); + + agent.call(List.of(userMsg("hello"))).block(TIMEOUT); + + // Without sysPrompt, the seed is null + assertTrue( + capturedSysMsg.get() == null || capturedSysMsg.get().getTextContent().isBlank(), + "Expected null or blank systemMsg when sysPrompt is empty"); + } + } + + // ========================================================================= + + @Nested + @DisplayName("systemMsg propagated to reasoning") + class SystemMsgPropagation { + + @Test + @DisplayName("systemMsg set by PreCallEvent hook appears as first message in model input") + void preCallHook_systemMsg_prependedToModelInput() { + List> modelInputCapture = new ArrayList<>(); + + when(mockModel.getModelName()).thenReturn("stub"); + when(mockModel.stream(anyList(), any(), any())) + .thenAnswer( + invocation -> { + @SuppressWarnings("unchecked") + List msgs = invocation.getArgument(0); + modelInputCapture.add(new ArrayList<>(msgs)); + ChatResponse resp = + ChatResponse.builder() + .content( + List.of( + TextBlock.builder() + .text("ok") + .build())) + .build(); + return Flux.just(resp); + }); + + ReActAgent agent = + ReActAgent.builder() + .name("agent") + .model(mockModel) + .memory(memory) + .sysPrompt("base prompt") + .maxIters(1) + .hook( + new Hook() { + @Override + public Mono onEvent(T event) { + if (event instanceof PreCallEvent) { + event.appendSystemContent(" extra context"); + } + return Mono.just(event); + } + }) + .build(); + + agent.call(List.of(userMsg("hi"))).block(TIMEOUT); + + assertEquals(1, modelInputCapture.size(), "model.stream should be called once"); + List input = modelInputCapture.get(0); + assertTrue(input.size() >= 1, "model should receive at least one message"); + + Msg firstMsg = input.get(0); + assertEquals(MsgRole.SYSTEM, firstMsg.getRole(), "first message must be SYSTEM"); + String sysText = firstMsg.getTextContent(); + assertTrue(sysText.contains("base prompt"), "systemMsg should contain sysPrompt"); + assertTrue( + sysText.contains("extra context"), + "systemMsg should include text appended by hook"); + } + } + + // ========================================================================= + + @Nested + @DisplayName("PreCallEvent full message view") + class PreCallFullMessageView { + + @Test + @DisplayName("PreCallEvent inputMessages contains memory snapshot + callArgs") + void preCallEvent_containsMemoryPlusCallArgs() { + // Seed memory with one prior message + Msg priorMsg = userMsg("prior turn"); + memory.addMessage(priorMsg); + + AtomicReference> capturedInput = new AtomicReference<>(); + + stubModelText("done"); + ReActAgent agent = + ReActAgent.builder() + .name("agent") + .model(mockModel) + .memory(memory) + .maxIters(1) + .hook( + new Hook() { + @Override + public Mono onEvent(T event) { + if (event instanceof PreCallEvent pre) { + capturedInput.set( + new ArrayList<>(pre.getInputMessages())); + } + return Mono.just(event); + } + }) + .build(); + + Msg callArg = userMsg("new message"); + agent.call(List.of(callArg)).block(TIMEOUT); + + List input = capturedInput.get(); + assertNotNull(input); + assertEquals(2, input.size(), "inputMessages should be [prior, callArg]"); + assertEquals( + "prior turn", + input.get(0).getTextContent(), + "first message should be the memory snapshot"); + assertEquals( + "new message", + input.get(1).getTextContent(), + "second message should be the call argument"); + } + } + + // ========================================================================= + + @Nested + @DisplayName("SYSTEM-in-tail guard") + class SystemInTailGuard { + + @Test + @DisplayName("Hook injecting SYSTEM into inputMessages tail throws IllegalStateException") + void hook_injectingSystemIntoTail_throws() { + stubModelText("done"); + ReActAgent agent = + ReActAgent.builder() + .name("agent") + .model(mockModel) + .memory(memory) + .maxIters(1) + .hook( + new Hook() { + @Override + public Mono onEvent(T event) { + if (event instanceof PreCallEvent pre) { + // Forbidden: append SYSTEM to inputMessages + List msgs = + new ArrayList<>(pre.getInputMessages()); + msgs.add( + Msg.builder() + .role(MsgRole.SYSTEM) + .content( + TextBlock.builder() + .text("bad") + .build()) + .build()); + pre.setInputMessages(msgs); + } + return Mono.just(event); + } + }) + .build(); + + assertThrows( + Exception.class, + () -> agent.call(List.of(userMsg("hello"))).block(TIMEOUT), + "Should throw when SYSTEM message appears in inputMessages tail"); + } + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/model/ModelRegistryTest.java b/agentscope-core/src/test/java/io/agentscope/core/model/ModelRegistryTest.java new file mode 100644 index 000000000..7d0bd52df --- /dev/null +++ b/agentscope-core/src/test/java/io/agentscope/core/model/ModelRegistryTest.java @@ -0,0 +1,139 @@ +/* + * 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.model; + +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.message.Msg; +import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfEnvironmentVariable; +import reactor.core.publisher.Flux; + +class ModelRegistryTest { + + @BeforeEach + void setUp() { + ModelRegistry.reset(); + } + + @AfterEach + void tearDown() { + ModelRegistry.reset(); + } + + @Test + void resolve_namedInstance_returnsRegistered() { + Model m = new StubModel("x"); + ModelRegistry.register("my-model", m); + assertSame(m, ModelRegistry.resolve("my-model")); + } + + @Test + void resolve_namedInstance_precedesFactory() { + Model named = new StubModel("named"); + ModelRegistry.register("openai:fake", named); + AtomicInteger factoryCalls = new AtomicInteger(); + ModelRegistry.registerFactory( + "openai:(.+)", + id -> { + factoryCalls.incrementAndGet(); + return new StubModel("factory"); + }); + assertSame(named, ModelRegistry.resolve("openai:fake")); + assertSame(named, ModelRegistry.resolve("openai:fake")); + assertTrue(factoryCalls.get() == 0); + } + + @Test + @EnabledIfEnvironmentVariable(named = "OPENAI_API_KEY", matches = ".+") + void resolve_openaiFormat_createsOpenAIChatModel() { + Model m = ModelRegistry.resolve("openai:gpt-4o-mini"); + assertInstanceOf(OpenAIChatModel.class, m); + } + + @Test + @EnabledIfEnvironmentVariable(named = "DASHSCOPE_API_KEY", matches = ".+") + void resolve_dashscopeShortFormat_createsDashScopeChatModel() { + Model m = ModelRegistry.resolve("qwen-max"); + assertInstanceOf(DashScopeChatModel.class, m); + } + + @Test + void resolve_unknownModel_throwsWithHelpMessage() { + IllegalArgumentException ex = + assertThrows( + IllegalArgumentException.class, + () -> ModelRegistry.resolve("totally-unknown-id")); + assertTrue(ex.getMessage().contains("Cannot resolve model")); + assertTrue(ex.getMessage().contains("OPENAI_API_KEY")); + } + + @Test + void resolve_caching_returnsSameInstance() { + Model a = ModelRegistry.resolve("ollama:llama3"); + Model b = ModelRegistry.resolve("ollama:llama3"); + assertSame(a, b); + } + + @Test + void registerFactory_userFactory_takesPriorityOverBuiltin() { + Model custom = new StubModel("custom-openai"); + ModelRegistry.registerFactory("openai:(.+)", id -> custom); + assertSame(custom, ModelRegistry.resolve("openai:anything")); + } + + @Test + void canResolve_knownOpenAiPattern_returnsTrue() { + assertTrue(ModelRegistry.canResolve("openai:gpt-5.5")); + } + + @Test + void canResolve_unknownPattern_returnsFalse() { + org.junit.jupiter.api.Assertions.assertFalse( + ModelRegistry.canResolve("unknown-provider:x")); + } + + @Test + void resolve_blankModelId_throws() { + assertThrows(IllegalArgumentException.class, () -> ModelRegistry.resolve(" ")); + } + + private static final class StubModel implements Model { + private final String name; + + StubModel(String name) { + this.name = name; + } + + @Override + public Flux stream( + List messages, List tools, GenerateOptions options) { + return Flux.empty(); + } + + @Override + public String getModelName() { + return name; + } + } +} diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillHookTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillHookTest.java index cf87493c1..3df4f3822 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillHookTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillHookTest.java @@ -25,6 +25,7 @@ import io.agentscope.core.agent.StructuredOutputHook; import io.agentscope.core.hook.Hook; import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PreCallEvent; import io.agentscope.core.hook.PreReasoningEvent; import io.agentscope.core.interruption.InterruptContext; import io.agentscope.core.message.MessageMetadataKeys; @@ -51,8 +52,7 @@ * Unit tests for SkillHook. * *

    - * These tests verify that SkillHook correctly injects skill prompts during - * PreReasoningEvent. + * These tests verify that SkillHook correctly injects skill prompts during {@link PreCallEvent}. */ @Tag("unit") class SkillHookTest { @@ -84,7 +84,7 @@ void testInjectSkillPromptWhenSkillsActive() { // Verify skill is now active assertTrue(skillBox.isSkillActive(skill.getSkillId()), "Skill should be active"); - // Create PreReasoningEvent with one user message + // Create PreCallEvent with one user message List messages = new ArrayList<>(); messages.add( Msg.builder() @@ -92,23 +92,19 @@ void testInjectSkillPromptWhenSkillsActive() { .content(TextBlock.builder().text("User query").build()) .build()); - PreReasoningEvent event = - new PreReasoningEvent( - testAgent, "test-model", GenerateOptions.builder().build(), messages); + PreCallEvent event = new PreCallEvent(testAgent, messages); // Act: Process event through hook - PreReasoningEvent result = skillHook.onEvent(event).block(); + PreCallEvent result = skillHook.onEvent(event).block(); - // Assert: Skill prompt should be injected + // Assert: Skill prompt should be injected into systemMsg, not inputMessages assertNotNull(result, "Event should be processed"); - assertEquals(2, result.getInputMessages().size(), "Should add skill prompt message"); - assertEquals( - MsgRole.SYSTEM, - result.getInputMessages().get(0).getRole(), - "Skill prompt should be SYSTEM message"); + assertEquals(1, result.getInputMessages().size(), "Should not add SYSTEM to inputMessages"); + assertNotNull(result.getSystemMessage(), "systemMsg should be set by SkillHook"); + assertEquals(MsgRole.SYSTEM, result.getSystemMessage().getRole()); assertTrue( - result.getInputMessages().get(0).getContent().toString().contains("test_skill"), - "Skill prompt should contain skill information"); + result.getSystemMessage().getTextContent().contains("test_skill"), + "systemMsg should contain skill information"); } /** @@ -150,23 +146,16 @@ void testInjectPromptForRegisteredSkills() { .content(TextBlock.builder().text("User query").build()) .build()); - PreReasoningEvent event = - new PreReasoningEvent( - testAgent, "test-model", GenerateOptions.builder().build(), messages); + PreCallEvent event = new PreCallEvent(testAgent, messages); // Act: Process event through hook - PreReasoningEvent result = skillHook.onEvent(event).block(); + PreCallEvent result = skillHook.onEvent(event).block(); - // Assert: Skill prompt should be added for registered skills + // Assert: Skill prompt should be in systemMsg, not inputMessages assertNotNull(result, "Event should be processed"); - assertEquals( - 2, - result.getInputMessages().size(), - "Should add skill prompt for registered skills"); - assertEquals( - MsgRole.SYSTEM, - result.getInputMessages().get(0).getRole(), - "Skill prompt should be SYSTEM message"); + assertEquals(1, result.getInputMessages().size(), "Should not add SYSTEM to inputMessages"); + assertNotNull(result.getSystemMessage(), "systemMsg should be set for registered skills"); + assertEquals(MsgRole.SYSTEM, result.getSystemMessage().getRole()); } @Test @@ -180,12 +169,10 @@ void testHandleEmptySkillPromptGracefully() { .content(TextBlock.builder().text("User query").build()) .build()); - PreReasoningEvent event = - new PreReasoningEvent( - testAgent, "test-model", GenerateOptions.builder().build(), messages); + PreCallEvent event = new PreCallEvent(testAgent, messages); // Act: Process event through hook - PreReasoningEvent result = skillHook.onEvent(event).block(); + PreCallEvent result = skillHook.onEvent(event).block(); // Assert: Should handle gracefully without adding prompt assertNotNull(result, "Event should be processed"); @@ -195,7 +182,10 @@ void testHandleEmptySkillPromptGracefully() { @Test @DisplayName("Should return correct hook priority") void testHookPriority() { - assertEquals(55, skillHook.priority(), "Skill hook should have priority (55)"); + assertEquals( + SkillHook.SKILL_HOOK_PRIORITY, + skillHook.priority(), + "Skill hook should use SKILL_HOOK_PRIORITY"); } @Test @@ -230,10 +220,8 @@ void testSkillPromptAppendedAfterStructuredOutputReminder() { new PreReasoningEvent( testAgent, "test-model", GenerateOptions.builder().build(), messages); - // Simulate AgentBase hook execution (SkillHook priority 55 > StructuredOutputHook - // priority 50) + // SkillHook only handles PreCall; structured output is applied on PreReasoning here. List hooks = new ArrayList<>(); - hooks.add(skillHook); hooks.add(new StructuredOutputHook(StructuredOutputReminder.TOOL_CHOICE, null, null)); PreReasoningEvent result = notifyHooks(event, hooks).block(); @@ -255,48 +243,47 @@ void testInjectSkillPromptAtFirst() { skillBox.registerSkill(skill); activateSkill(skill.getSkillId()); - // Create PreReasoningEvent with multiple messages (no existing SYSTEM message) + // Create PreCallEvent with multiple messages (no existing SYSTEM message) List messages = - List.of( - Msg.builder() - .role(MsgRole.USER) - .content(TextBlock.builder().text("User query").build()) - .build(), - Msg.builder() - .role(MsgRole.ASSISTANT) - .content(TextBlock.builder().text("Assistant response").build()) - .build()); - - PreReasoningEvent event = - new PreReasoningEvent( - testAgent, "test-model", GenerateOptions.builder().build(), messages); + new ArrayList<>( + List.of( + Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text("User query").build()) + .build(), + Msg.builder() + .role(MsgRole.ASSISTANT) + .content( + TextBlock.builder() + .text("Assistant response") + .build()) + .build())); + + PreCallEvent event = new PreCallEvent(testAgent, messages); // Act: Process event through hook - PreReasoningEvent result = skillHook.onEvent(event).block(); + PreCallEvent result = skillHook.onEvent(event).block(); - // Assert: Skill prompt should be injected at the FIRST position + // Assert: Skill prompt should be in systemMsg; inputMessages unchanged assertNotNull(result, "Event should be processed"); - assertEquals(3, result.getInputMessages().size(), "Should add skill prompt message"); - - // Verify the first message is the skill prompt (SYSTEM role) - Msg firstMsg = result.getInputMessages().get(0); assertEquals( - MsgRole.SYSTEM, - firstMsg.getRole(), - "First message should be SYSTEM message with skill prompt"); + 2, + result.getInputMessages().size(), + "inputMessages should not gain a SYSTEM entry"); + assertNotNull(result.getSystemMessage(), "systemMsg should be set"); assertTrue( - firstMsg.getTextContent().contains("test_skill"), - "First message should contain skill information"); + result.getSystemMessage().getTextContent().contains("test_skill"), + "systemMsg should contain skill information"); - // Verify original messages are preserved in order after skill prompt + // Verify original messages are preserved in order assertEquals( "User query", - result.getInputMessages().get(1).getTextContent(), - "Second message should be original user query"); + result.getInputMessages().get(0).getTextContent(), + "First message should be original user query"); assertEquals( "Assistant response", - result.getInputMessages().get(2).getTextContent(), - "Third message should be original assistant response"); + result.getInputMessages().get(1).getTextContent(), + "Second message should be original assistant response"); } @Test @@ -309,65 +296,51 @@ void testMergeSkillPromptIntoExistingSystemMessage() { skillBox.registerSkill(skill); activateSkill(skill.getSkillId()); - // Create PreReasoningEvent with an existing SYSTEM message + // Create PreCallEvent with an existing SYSTEM message List messages = - List.of( - Msg.builder() - .role(MsgRole.SYSTEM) - .content(TextBlock.builder().text("System instruction").build()) - .build(), - Msg.builder() - .role(MsgRole.USER) - .content(TextBlock.builder().text("User query").build()) - .build(), - Msg.builder() - .role(MsgRole.ASSISTANT) - .content(TextBlock.builder().text("Assistant response").build()) - .build()); - - PreReasoningEvent event = - new PreReasoningEvent( - testAgent, "test-model", GenerateOptions.builder().build(), messages); + new ArrayList<>( + List.of( + Msg.builder() + .role(MsgRole.SYSTEM) + .content( + TextBlock.builder() + .text("System instruction") + .build()) + .build(), + Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text("User query").build()) + .build(), + Msg.builder() + .role(MsgRole.ASSISTANT) + .content( + TextBlock.builder() + .text("Assistant response") + .build()) + .build())); + + PreCallEvent event = new PreCallEvent(testAgent, messages); // Act: Process event through hook - PreReasoningEvent result = skillHook.onEvent(event).block(); + PreCallEvent result = skillHook.onEvent(event).block(); - // Assert: Should still have exactly 3 messages (merged, not added) + // Assert: SkillHook uses systemMsg API now; inputMessages are NOT modified. + // The pre-existing SYSTEM message in inputMessages stays there (it's in the caller's list), + // and the skill prompt is appended to event.getSystemMessage(). assertNotNull(result, "Event should be processed"); + + // inputMessages still has 3 items (unchanged by SkillHook) assertEquals( - 3, - result.getInputMessages().size(), - "Should merge into existing SYSTEM message, not add a new one"); - - // Verify there is exactly one SYSTEM message - long systemCount = - result.getInputMessages().stream() - .filter(m -> m.getRole() == MsgRole.SYSTEM) - .count(); - assertEquals(1, systemCount, "There should be exactly one SYSTEM message"); - - // Verify the merged SYSTEM message is at index 0 - Msg systemMsg = result.getInputMessages().get(0); - assertEquals(MsgRole.SYSTEM, systemMsg.getRole()); - - // Verify structural merge: content blocks are preserved, not flattened - // First content block should be the original system instruction TextBlock, - // second should be the skill prompt TextBlock - assertEquals( - 2, - systemMsg.getContent().size(), - "Merged SYSTEM message should have 2 content blocks (structural merge)"); - assertInstanceOf(TextBlock.class, systemMsg.getContent().get(0)); - assertInstanceOf(TextBlock.class, systemMsg.getContent().get(1)); - assertEquals( - "System instruction", - ((TextBlock) systemMsg.getContent().get(0)).getText(), - "First content block should be the original system instruction"); + 3, result.getInputMessages().size(), "SkillHook should not modify inputMessages"); + + // systemMsg should contain the skill prompt + assertNotNull(result.getSystemMessage(), "systemMsg should be set by SkillHook"); + assertEquals(MsgRole.SYSTEM, result.getSystemMessage().getRole()); assertTrue( - ((TextBlock) systemMsg.getContent().get(1)).getText().contains("test_skill"), - "Second content block should be the skill prompt"); + result.getSystemMessage().getTextContent().contains("test_skill"), + "systemMsg should contain skill information"); - // Verify other messages are preserved + // Verify original messages are preserved in order assertEquals("User query", result.getInputMessages().get(1).getTextContent()); assertEquals("Assistant response", result.getInputMessages().get(2).getTextContent()); } diff --git a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java index 8e7bf1895..0fa36d793 100644 --- a/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java +++ b/agentscope-core/src/test/java/io/agentscope/core/skill/SkillRuntimeIntegrationTest.java @@ -23,14 +23,13 @@ import io.agentscope.core.agent.Agent; import io.agentscope.core.agent.AgentBase; import io.agentscope.core.hook.Hook; -import io.agentscope.core.hook.PreReasoningEvent; +import io.agentscope.core.hook.PreCallEvent; import io.agentscope.core.interruption.InterruptContext; import io.agentscope.core.message.Msg; import io.agentscope.core.message.MsgRole; import io.agentscope.core.message.TextBlock; import io.agentscope.core.message.ToolResultBlock; import io.agentscope.core.message.ToolUseBlock; -import io.agentscope.core.model.GenerateOptions; import io.agentscope.core.tool.AgentTool; import io.agentscope.core.tool.ToolCallParam; import io.agentscope.core.tool.Toolkit; @@ -134,7 +133,7 @@ void testCompleteSkillActivationFlow() { assertNotNull( toolkit.getTool("calculator_multiply"), "calculator_multiply should be accessible"); - // Step 6: Trigger PreReasoningEvent to inject skill prompt + // Step 6: Trigger PreCallEvent to inject skill prompt List messages = new ArrayList<>(); messages.add( Msg.builder() @@ -142,19 +141,20 @@ void testCompleteSkillActivationFlow() { .content(TextBlock.builder().text("Calculate 2 + 3").build()) .build()); - PreReasoningEvent preReasoningEvent = - new PreReasoningEvent( - testAgent, "test-model", GenerateOptions.builder().build(), messages); - PreReasoningEvent result = skillHook.onEvent(preReasoningEvent).block(); + PreCallEvent preCallEvent = new PreCallEvent(testAgent, messages); + PreCallEvent result = skillHook.onEvent(preCallEvent).block(); - // Step 7: Verify skill prompt was injected - assertNotNull(result, "PreReasoningEvent should be processed"); + // Step 7: Verify skill prompt was injected into systemMsg + assertNotNull(result, "PreCallEvent should be processed"); assertEquals( - 2, result.getInputMessages().size(), "Should add skill prompt to input messages"); + 1, + result.getInputMessages().size(), + "inputMessages should not gain a SYSTEM entry"); + assertNotNull(result.getSystemMessage(), "systemMsg should be set by SkillHook"); assertEquals( MsgRole.SYSTEM, - result.getInputMessages().get(0).getRole(), - "Skill prompt should be SYSTEM message"); + result.getSystemMessage().getRole(), + "systemMsg should be SYSTEM role"); // Step 8: Verify skill and tool group remain active assertTrue(skillBox.isSkillActive(skillId), "Skill should remain active"); diff --git a/agentscope-distribution/agentscope-all/pom.xml b/agentscope-distribution/agentscope-all/pom.xml index 35af755df..78c86323d 100644 --- a/agentscope-distribution/agentscope-all/pom.xml +++ b/agentscope-distribution/agentscope-all/pom.xml @@ -45,6 +45,13 @@ true + + io.agentscope + agentscope-harness + compile + true + + io.agentscope agentscope-extensions-a2a-client diff --git a/agentscope-distribution/agentscope-bom/pom.xml b/agentscope-distribution/agentscope-bom/pom.xml index 42e269108..4f6bf06c0 100644 --- a/agentscope-distribution/agentscope-bom/pom.xml +++ b/agentscope-distribution/agentscope-bom/pom.xml @@ -86,6 +86,12 @@ ${project.version} + + io.agentscope + agentscope-harness + ${project.version} + + io.agentscope diff --git a/agentscope-examples/harness-examples/harness-example-common/pom.xml b/agentscope-examples/harness-examples/harness-example-common/pom.xml new file mode 100644 index 000000000..575d19d57 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-common/pom.xml @@ -0,0 +1,53 @@ + + + + + 4.0.0 + + io.agentscope + harness-examples + ${revision} + ../pom.xml + + + harness-example-common + io.agentscope.examples + AgentScope Java - Harness Example Common + jar + + + + io.agentscope + agentscope-harness + + + com.fasterxml.jackson.core + jackson-databind + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + org.apache.logging.log4j + log4j-slf4j2-impl + ${log4j2.version} + + + diff --git a/agentscope-examples/harness-examples/harness-example-common/src/main/java/io/agentscope/examples/harness/common/model/FixedReplyModel.java b/agentscope-examples/harness-examples/harness-example-common/src/main/java/io/agentscope/examples/harness/common/model/FixedReplyModel.java new file mode 100644 index 000000000..53a327e6c --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-common/src/main/java/io/agentscope/examples/harness/common/model/FixedReplyModel.java @@ -0,0 +1,64 @@ +/* + * 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.harness.common.model; + +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import reactor.core.publisher.Flux; + +/** + * A minimal, deterministic {@link Model} implementation for examples: one assistant text turn + * with finish reason {@code stop}, so the agent loop can complete without remote LLM calls. + */ +public final class FixedReplyModel implements Model { + + private final String modelName; + private final String replyText; + + public FixedReplyModel(String modelName, String replyText) { + this.modelName = modelName; + this.replyText = replyText; + } + + public static FixedReplyModel done() { + return new FixedReplyModel("fixed-reply", "done"); + } + + @Override + public String getModelName() { + return modelName; + } + + @Override + public Flux stream( + List messages, List tools, GenerateOptions options) { + ChatResponse chunk = + new ChatResponse( + "local-" + UUID.randomUUID(), + List.of(TextBlock.builder().text(replyText).build()), + null, + Map.of(), + "stop"); + return Flux.just(chunk); + } +} diff --git a/agentscope-examples/harness-examples/harness-example-common/src/main/java/io/agentscope/examples/harness/common/util/ExampleUtils.java b/agentscope-examples/harness-examples/harness-example-common/src/main/java/io/agentscope/examples/harness/common/util/ExampleUtils.java new file mode 100644 index 000000000..a2c62e025 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-common/src/main/java/io/agentscope/examples/harness/common/util/ExampleUtils.java @@ -0,0 +1,490 @@ +/* + * 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.harness.common.util; + +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.EventType; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.agent.StreamOptions; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; +import io.agentscope.harness.agent.HarnessAgent; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; + +/** + * Utility class providing common functionality for examples. + * + *

    + * Features: + * + *

      + *
    • Interactive API key configuration + *
    • Chat loop implementation + *
    • Helper methods for user interaction + *
    + */ +public class ExampleUtils { + + private static final BufferedReader reader = + new BufferedReader(new InputStreamReader(System.in)); + + /** + * Get DashScope API key from environment variable or interactive input. + * + * @return API key + * @throws IOException if input fails + */ + public static String getDashScopeApiKey() throws IOException { + return getApiKey( + "DASHSCOPE_API_KEY", "DashScope", "https://dashscope.console.aliyun.com/apiKey"); + } + + /** + * Get OpenAI API key from environment variable or interactive input. + * + * @return API key + * @throws IOException if input fails + */ + public static String getOpenAIApiKey() throws IOException { + return getApiKey("OPENAI_API_KEY", "OpenAI", "https://platform.openai.com/api-keys"); + } + + /** + * Get API key from environment variable or interactive input. + * + * @param envVarName environment variable name + * @param serviceName service name for display + * @param helpUrl URL to get API key + * @return API key + * @throws IOException if input fails + */ + public static String getApiKey(String envVarName, String serviceName, String helpUrl) + throws IOException { + + // 1. Try environment variable + String apiKey = System.getenv(envVarName); + + if (apiKey != null && !apiKey.isEmpty()) { + System.out.println("✓ Using API key from environment variable " + envVarName + "\n"); + return apiKey; + } + + // 2. Interactive input + System.out.println(envVarName + " environment variable not found.\n"); + System.out.println("Please enter your " + serviceName + " API Key:"); + System.out.println("(Get one at: " + helpUrl + ")"); + System.out.print("\nAPI Key: "); + + apiKey = reader.readLine().trim(); + + if (apiKey.isEmpty()) { + System.err.println("Error: API Key cannot be empty"); + System.exit(1); + } + + System.out.println("\n✓ API Key configured"); + System.out.println("Tip: Set environment variable to skip this step:"); + System.out.println(" export " + envVarName + "=" + maskApiKey(apiKey) + "\n"); + + return apiKey; + } + + /** + * Mask API key for display (show first 4 and last 4 characters). + * + * @param apiKey API key to mask + * @return masked API key + */ + public static String maskApiKey(String apiKey) { + if (apiKey == null || apiKey.length() <= 8) { + return "***"; + } + return apiKey.substring(0, 4) + "****" + apiKey.substring(apiKey.length() - 4); + } + + /** + * Start an interactive chat loop with an agent. + * + * @param agent the agent to chat with + * @throws IOException if input fails + */ + public static void startChat(Agent agent) throws IOException { + System.out.println("=== Chat Started ==="); + System.out.println("Type 'exit' to quit\n"); + + while (true) { + System.out.print("You> "); + String input = reader.readLine(); + + if (input == null || "exit".equalsIgnoreCase(input.trim())) { + System.out.println("Goodbye!"); + break; + } + + if (input.trim().isEmpty()) { + continue; + } + + try { + Msg userMsg = + Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text(input).build()) + .build(); + + System.out.print("Agent> "); + + try { + // Try to use stream() first for real-time output + AtomicBoolean hasPrintedThinkingHeader = new AtomicBoolean(false); + AtomicBoolean hasPrintedTextHeader = new AtomicBoolean(false); + AtomicBoolean hasPrintedTextSeparator = new AtomicBoolean(false); + AtomicReference lastThinkingContent = new AtomicReference<>(""); + AtomicReference lastTextContent = new AtomicReference<>(""); + + StreamOptions streamOptions = + StreamOptions.builder() + .eventTypes(EventType.REASONING, EventType.TOOL_RESULT) + .incremental(true) + .includeReasoningResult(false) + .build(); + + agent.stream(userMsg, streamOptions) + .doOnNext( + event -> { + Msg msg = event.getMessage(); + for (ContentBlock block : msg.getContent()) { + if (block instanceof ThinkingBlock) { + printStreamContent( + ((ThinkingBlock) block).getThinking(), + lastThinkingContent, + hasPrintedThinkingHeader, + "> Thinking: ", + null); + } else if (block instanceof TextBlock) { + printStreamContent( + ((TextBlock) block).getText(), + lastTextContent, + hasPrintedTextHeader, + "Text: ", + () -> { + if (hasPrintedThinkingHeader.get() + && !hasPrintedTextSeparator + .get()) { + System.out.print("\n\n"); + hasPrintedTextSeparator.set(true); + } + }); + } + } + }) + .blockLast(); + } catch (Exception e) { + // Fallback to call() if streaming is not supported or fails + if (e instanceof UnsupportedOperationException) { + System.err.println( + "\n[Info] Streaming not supported by this agent. Falling back to" + + " call()."); + } else { + System.err.println( + "\n[Warning] Exception during streaming: " + e.getMessage()); + e.printStackTrace(); + System.err.println("[Info] Falling back to call()."); + } + + Msg response = agent.call(userMsg).block(); + if (response != null) { + // Extract thinking and text separately to match streaming format + String thinking = + response.getContent().stream() + .filter(block -> block instanceof ThinkingBlock) + .map(block -> ((ThinkingBlock) block).getThinking()) + .collect(Collectors.joining("\n")); + + String text = + response.getContent().stream() + .filter(block -> block instanceof TextBlock) + .map(block -> ((TextBlock) block).getText()) + .collect(Collectors.joining("\n")); + + boolean hasContent = false; + if (!thinking.isEmpty()) { + System.out.print("> Thinking: " + thinking); + hasContent = true; + } + if (!text.isEmpty()) { + if (hasContent) { + System.out.print("\n\n"); + } + System.out.print("Text: " + text); + hasContent = true; + } + if (!hasContent) { + System.out.print("[No response]"); + } + } + } + + System.out.println("\n"); + + } catch (Exception e) { + System.err.println("\nError: " + e.getMessage()); + e.printStackTrace(); + } + } + } + + /** + * Read a line from user input. + * + * @return user input + * @throws IOException if input fails + */ + public static String readLine() throws IOException { + return reader.readLine(); + } + + /** + * Print a welcome banner. + * + * @param title example title + * @param description example description + */ + public static void printWelcome(String title, String description) { + System.out.println("=== " + title + " ===\n"); + System.out.println(description); + System.out.println(); + } + + /** + * Extract text content from a message. + * + * @param msg message to extract text from + * @return extracted text + */ + public static String textOf(Msg msg) { + return MsgUtils.getTextContent(msg); + } + + public static Msg userMsg(String text) { + return MsgUtils.textMsg(null, MsgRole.USER, text); + } + + public static RuntimeContext ctx(String sessionId, String userId) { + return RuntimeContext.builder().sessionId(sessionId).userId(userId).build(); + } + + public static RuntimeContext ctx(String sessionId) { + return RuntimeContext.builder().sessionId(sessionId).build(); + } + + public static void startChat(HarnessAgent agent, RuntimeContext ctx) throws IOException { + startHarnessChat(agent, ctx); + } + + public static void startHarnessChat(HarnessAgent agent, RuntimeContext ctx) throws IOException { + System.out.println("=== Chat Started ==="); + System.out.println("Type 'exit', 'quit', or 'q' to quit\n"); + + while (true) { + System.out.print("You> "); + String input = reader.readLine(); + + if (input == null) { + System.out.println("\n(end of input)"); + break; + } + + String question = input.trim(); + if (question.isEmpty()) { + System.out.println("Goodbye!"); + break; + } + + if (isQuitCommand(question)) { + System.out.println("Goodbye!"); + break; + } + + try { + runHarnessTurn(agent, ctx, question); + System.out.println(); + } catch (Exception e) { + System.err.println("\nError: " + e.getMessage()); + e.printStackTrace(); + } + } + } + + public static void runHarnessTurn(HarnessAgent agent, RuntimeContext ctx, String question) { + Msg userMsg = userMsg(question); + System.out.print("Agent> "); + try { + streamHarnessTurn(agent, ctx, userMsg); + } catch (Exception e) { + if (e instanceof UnsupportedOperationException) { + System.err.println( + "\n[Info] Streaming not supported by this agent. Falling back to call()."); + } else { + System.err.println("\n[Warning] Exception during streaming: " + e.getMessage()); + e.printStackTrace(); + System.err.println("[Info] Falling back to call()."); + } + Msg response = agent.call(userMsg, ctx).block(); + printResponseContent(response); + } + System.out.println("\n"); + } + + private static void streamHarnessTurn(HarnessAgent agent, RuntimeContext ctx, Msg userMsg) { + AtomicBoolean hasPrintedThinkingHeader = new AtomicBoolean(false); + AtomicBoolean hasPrintedTextHeader = new AtomicBoolean(false); + AtomicBoolean hasPrintedTextSeparator = new AtomicBoolean(false); + AtomicReference lastThinkingContent = new AtomicReference<>(""); + AtomicReference lastTextContent = new AtomicReference<>(""); + + StreamOptions streamOptions = + StreamOptions.builder() + .eventTypes(EventType.REASONING, EventType.TOOL_RESULT) + .incremental(true) + .includeReasoningResult(false) + .build(); + + agent.stream(List.of(userMsg), streamOptions, ctx) + .doOnNext( + event -> { + Msg msg = event.getMessage(); + for (ContentBlock block : msg.getContent()) { + if (block instanceof ThinkingBlock) { + printStreamContent( + ((ThinkingBlock) block).getThinking(), + lastThinkingContent, + hasPrintedThinkingHeader, + "> Thinking: ", + null); + } else if (block instanceof TextBlock) { + printStreamContent( + ((TextBlock) block).getText(), + lastTextContent, + hasPrintedTextHeader, + "Text: ", + () -> { + if (hasPrintedThinkingHeader.get() + && !hasPrintedTextSeparator.get()) { + System.out.print("\n\n"); + hasPrintedTextSeparator.set(true); + } + }); + } + } + }) + .blockLast(); + } + + private static void printResponseContent(Msg response) { + if (response == null) { + System.out.print("[No response]"); + return; + } + String thinking = + response.getContent().stream() + .filter(block -> block instanceof ThinkingBlock) + .map(block -> ((ThinkingBlock) block).getThinking()) + .collect(Collectors.joining("\n")); + + String text = + response.getContent().stream() + .filter(block -> block instanceof TextBlock) + .map(block -> ((TextBlock) block).getText()) + .collect(Collectors.joining("\n")); + + boolean hasContent = false; + if (!thinking.isEmpty()) { + System.out.print("> Thinking: " + thinking); + hasContent = true; + } + if (!text.isEmpty()) { + if (hasContent) { + System.out.print("\n\n"); + } + System.out.print("Text: " + text); + hasContent = true; + } + if (!hasContent) { + System.out.print("[No response]"); + } + } + + private static boolean isQuitCommand(String line) { + return line.equalsIgnoreCase("quit") + || line.equalsIgnoreCase("exit") + || line.equalsIgnoreCase("q"); + } + + /** + * Helper method to print streaming content. + * + * @param content content to print + * @param lastContentRef reference to the last content for delta + * calculation + * @param hasPrintedHeaderRef reference to whether the header has been printed + * @param header header to print + * @param prePrintAction action to run before printing (e.g., adding + * separators) + */ + private static void printStreamContent( + String content, + AtomicReference lastContentRef, + AtomicBoolean hasPrintedHeaderRef, + String header, + Runnable prePrintAction) { + String lastContent = lastContentRef.get(); + String toPrint; + + // Detect if cumulative or incremental + if (content.startsWith(lastContent)) { + // Cumulative: print only new part + toPrint = content.substring(lastContent.length()); + lastContentRef.set(content); + } else { + // Incremental: print as-is and append + toPrint = content; + lastContentRef.set(lastContent + content); + } + + if (!toPrint.isEmpty()) { + if (prePrintAction != null) { + prePrintAction.run(); + } + + if (!hasPrintedHeaderRef.get()) { + System.out.print(header); + hasPrintedHeaderRef.set(true); + } + System.out.print(toPrint); + System.out.flush(); + } + } +} diff --git a/agentscope-examples/harness-examples/harness-example-common/src/main/java/io/agentscope/examples/harness/common/util/MsgUtils.java b/agentscope-examples/harness-examples/harness-example-common/src/main/java/io/agentscope/examples/harness/common/util/MsgUtils.java new file mode 100644 index 000000000..a9d7377aa --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-common/src/main/java/io/agentscope/examples/harness/common/util/MsgUtils.java @@ -0,0 +1,176 @@ +/* + * 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.harness.common.util; + +import io.agentscope.core.message.AudioBlock; +import io.agentscope.core.message.ImageBlock; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.Source; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ThinkingBlock; +import io.agentscope.core.message.VideoBlock; +import java.util.stream.Collectors; + +/** + * Utility methods for working with Msg in examples. These are convenience + * methods for common + * operations. + */ +public class MsgUtils { + + /** + * Extract text content from a message. Concatenates text from all + * text-containing blocks + * (TextBlock and ThinkingBlock). + * + * @param msg The message to extract text from + * @return Concatenated text content or empty string if not available + */ + public static String getTextContent(Msg msg) { + String thinking = + msg.getContent().stream() + .filter(block -> block instanceof ThinkingBlock) + .map(block -> ((ThinkingBlock) block).getThinking()) + .collect(Collectors.joining("\n")); + + String text = + msg.getContent().stream() + .filter(block -> block instanceof TextBlock) + .map(block -> ((TextBlock) block).getText()) + .collect(Collectors.joining("\n")); + + if (!thinking.isEmpty() && !text.isEmpty()) { + return thinking + "\n\n" + text; + } else if (!thinking.isEmpty()) { + return thinking; + } else if (!text.isEmpty()) { + return text; + } else { + return "[No response]"; + } + } + + /** + * Check if a message has text content. + * + * @param msg The message to check + * @return true if the message contains text content + */ + public static boolean hasTextContent(Msg msg) { + return msg.getContent().stream() + .anyMatch(block -> block instanceof TextBlock || block instanceof ThinkingBlock); + } + + /** + * Check if a message has media content. + * + * @param msg The message to check + * @return true if the message contains media content + */ + public static boolean hasMediaContent(Msg msg) { + return msg.getContent().stream() + .anyMatch( + block -> + block instanceof ImageBlock + || block instanceof AudioBlock + || block instanceof VideoBlock); + } + + /** + * Create a message with text content (convenience method). + * + * @param name Sender name + * @param role Message role + * @param text Text content + * @return Message with text content + */ + public static Msg textMsg(String name, MsgRole role, String text) { + return Msg.builder() + .name(name) + .role(role) + .content(TextBlock.builder().text(text).build()) + .build(); + } + + /** + * Create a message with image content (convenience method). + * + * @param name Sender name + * @param role Message role + * @param source Image source + * @return Message with image content + */ + public static Msg imageMsg(String name, MsgRole role, Source source) { + return Msg.builder() + .name(name) + .role(role) + .content(ImageBlock.builder().source(source).build()) + .build(); + } + + /** + * Create a message with audio content (convenience method). + * + * @param name Sender name + * @param role Message role + * @param source Audio source + * @return Message with audio content + */ + public static Msg audioMsg(String name, MsgRole role, Source source) { + return Msg.builder() + .name(name) + .role(role) + .content(AudioBlock.builder().source(source).build()) + .build(); + } + + /** + * Create a message with video content (convenience method). + * + * @param name Sender name + * @param role Message role + * @param source Video source + * @return Message with video content + */ + public static Msg videoMsg(String name, MsgRole role, Source source) { + return Msg.builder() + .name(name) + .role(role) + .content(VideoBlock.builder().source(source).build()) + .build(); + } + + /** + * Create a message with thinking content (convenience method). + * + * @param name Sender name + * @param role Message role + * @param thinking Thinking content + * @return Message with thinking content + */ + public static Msg thinkingMsg(String name, MsgRole role, String thinking) { + return Msg.builder() + .name(name) + .role(role) + .content(ThinkingBlock.builder().thinking(thinking).build()) + .build(); + } + + private MsgUtils() { + // Utility class + } +} diff --git a/agentscope-examples/harness-examples/harness-example-local/README.md b/agentscope-examples/harness-examples/harness-example-local/README.md new file mode 100644 index 000000000..342fa42e0 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-local/README.md @@ -0,0 +1,169 @@ +# harness-example-local — Filesystem **模式三(本机 + 宿主 Shell)** + +本模块是 **CLI 版 Chinook Text-to-SQL** 示例(`TextToSqlExample`),演示 **`HarnessAgent` 不显式配置 `filesystem(...)`** 时的默认行为:与 [`docs/zh/harness/filesystem.md`](../../docs/zh/harness/filesystem.md) 中的 **模式三**(以及 `HarnessAgent.Builder#filesystem(LocalFilesystemSpec)` / 源码注释 **Mode 3**)一致。 + +--- + +## 三种 Filesystem 示例对照 + +| 模式 | 文档(中文) | 典型 `HarnessAgent` 配置 | 本仓库模块 | +|------|----------------|---------------------------|------------| +| **模式一** | 复合 + 共享存储 | `.filesystem(new RemoteFilesystemSpec(store)...)` + 分布式 `Session` | [`harness-example-remote`](../harness-example-remote/README.md) | +| **模式二** | 沙箱 | `.filesystem(sandboxFilesystemSpec)` + `SandboxStateStore` 等 | [`harness-example-sandbox`](../harness-example-sandbox/README.md) | +| **模式三** | 本机 + shell | **不写** `filesystem(...)`,或 `.filesystem(new LocalFilesystemSpec())` | **本模块** | + +--- + +## 模式三在本示例里如何体现 + +`TextToSqlExample` 构建 Agent 时**没有**调用 `.filesystem(...)`: + +```java +HarnessAgent agent = + HarnessAgent.builder() + .name("text-to-sql") + .sysPrompt("...") + .model(modelId) // 例如 "dashscope:qwen-max",经 ModelRegistry 解析 + .workspace(workspace) + .enableAgentTracingLog(true) + .toolkit(toolkit) + .build(); +``` + +`HarnessAgent` 内部 `resolveFilesystem(...)` 在三种 Spec 都未配置时,**直接**返回 **`LocalFilesystemWithShell(workspace, namespaceFactory)`** —— 工作区根在本地目录,**`ShellExecuteTool` 在宿主上执行 `sh -c`**。这与「显式 `new LocalFilesystemSpec().toFilesystem(...)` 且保持默认参数」在能力上等价(显式 Spec 用于调节超时、`virtualMode`、环境变量等)。 + +**后果(设计上的取舍)**: + +- 适合 **单机 / 信任环境 / 本地开发**。 +- **不适合**把不受信 shell 暴露给多租户;也不自带跨副本的 `MEMORY.md` 共享(与模式一不同)。 + +--- + +## 业务与仓库布局(CLI) + +- **入口**:`io.agentscope.harness.example.TextToSqlExample` +- **工作区**:`WorkspaceInitializer` 将 `src/main/resources/workspace/` 解压到磁盘(默认 `.agentscope/workspace`)。 +- **工具**:`SqliteTool`(`sql_list_tables` / `sql_get_schema` / `sql_execute_query`)通过 `Toolkit` 注册。 +- **Chinook**:classpath 自带 `chinook-default.sqlite`,首次运行可复制到 `AGENTSCOPE_DB_PATH`(默认 `chinook.db`)。 + +详细目录树与自定义方式见下文「项目布局」与「自定义 Agent」。 + +--- + +## 快速开始 + +### 1. 构建 + +```bash +cd agentscope-java +mvn -pl agentscope-examples/harness-example-local -am package -DskipTests +``` + +### 2. 环境变量 + +```bash +export DASHSCOPE_API_KEY=your_key_here +# 可选:AGENTSCOPE_MODEL(默认 qwen-max)、AGENTSCOPE_WORKSPACE、AGENTSCOPE_DB_PATH +``` + +或复制 `.env.example` → `.env` 后自行 `export`。 + +### 3. 运行 + +本模块为**普通 JAR**(非 Spring Boot fat jar),需把 **`target/classes` + 依赖 classpath** 一并传给 `java`。 + +在 **`agentscope-examples/harness-example-local`** 目录下: + +```bash +mvn package -DskipTests +export CP="target/classes:$(mvn -q -DincludeScope=runtime dependency:build-classpath -Dmdep.outputFile=/dev/stdout)" +java -cp "$CP" io.agentscope.harness.example.TextToSqlExample +``` + +交互(无参数);单次问答可在末尾追加问题字符串,例如: + +```bash +java -cp "$CP" io.agentscope.harness.example.TextToSqlExample "What are the top 5 best-selling artists?" +``` + +从仓库根目录构建时: + +```bash +cd agentscope-java +mvn -pl agentscope-examples/harness-example-local -am package -DskipTests +cd agentscope-examples/harness-example-local +export CP="target/classes:$(mvn -q -DincludeScope=runtime dependency:build-classpath -Dmdep.outputFile=/dev/stdout)" +java -cp "$CP" io.agentscope.harness.example.TextToSqlExample +``` + +--- + +## 项目布局(节选) + +``` +agentscope-examples/harness-example-local/ +├── pom.xml +├── .env.example +├── README.md +└── src/main/ + ├── java/io/agentscope/harness/example/ + │ ├── TextToSqlExample.java + │ ├── SqliteTool.java + │ └── WorkspaceInitializer.java + └── resources/ + ├── log4j2.xml + ├── io/agentscope/harness/example/chinook-default.sqlite + └── workspace/ + ├── AGENTS.md + ├── MEMORY.md + ├── knowledge/KNOWLEDGE.md + ├── skills/... + └── subagents/... +``` + +--- + +## 调用与 `RuntimeContext` + +```java +RuntimeContext ctx = RuntimeContext.builder().sessionId(sessionId).build(); +Msg reply = agent.call(Msg.builder().role(MsgRole.USER).textContent(question).build(), ctx).block(); +``` + +`sessionId` 用于会话级状态与 memory hooks;本 CLI 默认可复用同一 session 以做多轮。 + +--- + +## 日志(Log4j2) + +模块使用 **Log4j2**(`log4j-slf4j2-impl`)。配置见 `src/main/resources/log4j2.xml`。可通过环境变量 **`AGENTSCOPE_LOG_LEVEL`**(如 `DEBUG`)调整 `io.agentscope.*` 日志级别。 + +--- + +## 自定义 Agent + +无需重新编译:直接编辑工作区目录下文件即可。 + +| 文件 | 作用 | +|------|------| +| `AGENTS.md` | 人设与规则 | +| `MEMORY.md` | 预置长期记忆 | +| `knowledge/KNOWLEDGE.md` | 领域知识(如 Chinook schema) | +| `skills/*/SKILL.md` | 技能流程 | +| `subagents/*.md` | 子 Agent 规格 | + +--- + +## 依赖 + +| 依赖 | 用途 | +|------|------| +| `agentscope-harness` | HarnessAgent、工具、Hook、工作区 | +| `jackson-dataformat-yaml` | Skill / subagent 的 YAML front matter | +| `sqlite-jdbc` | `SqliteTool` | + +--- + +## License + +Apache 2.0 — 见仓库根目录 `LICENSE`。 diff --git a/agentscope-examples/harness-examples/harness-example-local/pom.xml b/agentscope-examples/harness-examples/harness-example-local/pom.xml new file mode 100644 index 000000000..0b2e4fc26 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-local/pom.xml @@ -0,0 +1,63 @@ + + + + + 4.0.0 + + io.agentscope + harness-examples + ${revision} + ../pom.xml + + + AgentScope Java - Harness Example + harness-example-local + AgentScope Harness Example - Enhanced agent runtime with workspace context, subagent orchestration, memory management, and backend abstraction + + + 2.24.3 + + + + + io.agentscope + agentscope-harness + + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + + + org.xerial + sqlite-jdbc + 3.47.2.0 + + + + + org.apache.logging.log4j + log4j-slf4j2-impl + ${log4j2.version} + + + diff --git a/agentscope-examples/harness-examples/harness-example-local/src/main/java/io/agentscope/harness/example/SqliteTool.java b/agentscope-examples/harness-examples/harness-example-local/src/main/java/io/agentscope/harness/example/SqliteTool.java new file mode 100644 index 000000000..84650c783 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-local/src/main/java/io/agentscope/harness/example/SqliteTool.java @@ -0,0 +1,247 @@ +/* + * 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.example; + +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Custom tool that exposes SQLite database operations to the agent. + * + *

    Provides three tools the agent can call: + * + *

      + *
    • {@code sql_list_tables} — list all tables in the database + *
    • {@code sql_get_schema} — describe a table's columns, types, and sample rows + *
    • {@code sql_execute_query} — run a read-only SELECT query and return formatted results + *
    + * + *

    This class demonstrates how to wire a domain-specific tool into a {@link + * io.agentscope.harness.agent.HarnessAgent}. Register it via the agent's {@code Toolkit} before + * calling {@link io.agentscope.harness.agent.HarnessAgent.Builder#build()}. + */ +public class SqliteTool { + + private static final Logger log = LoggerFactory.getLogger(SqliteTool.class); + + /** Maximum rows returned by {@code sql_execute_query} to prevent context overflow. */ + private static final int MAX_ROWS = 50; + + /** Number of sample rows shown by {@code sql_get_schema}. */ + private static final int SAMPLE_ROWS = 3; + + private final String jdbcUrl; + + public SqliteTool(Path dbPath) { + this.jdbcUrl = "jdbc:sqlite:" + dbPath.toAbsolutePath(); + } + + @Tool( + name = "sql_list_tables", + description = + "Lists all tables in the SQLite database. Use this first to discover what data" + + " is available before writing queries.") + public String listTables() { + try (Connection conn = DriverManager.getConnection(jdbcUrl)) { + DatabaseMetaData meta = conn.getMetaData(); + List tables = new ArrayList<>(); + try (ResultSet rs = meta.getTables(null, null, "%", new String[] {"TABLE"})) { + while (rs.next()) { + tables.add(rs.getString("TABLE_NAME")); + } + } + if (tables.isEmpty()) { + return "No tables found in the database."; + } + return "Tables (" + tables.size() + "):\n" + String.join("\n", tables); + } catch (SQLException e) { + log.warn("sql_list_tables failed", e); + return "Error listing tables: " + e.getMessage(); + } + } + + @Tool( + name = "sql_get_schema", + description = + "Returns the schema (column names, types, keys) and sample rows for one or more" + + " tables. Pass a comma-separated list of table names to inspect" + + " multiple tables at once.") + public String getSchema( + @ToolParam( + name = "tables", + description = + "Comma-separated table names, e.g. \"Artist,Album,Track\"") + String tables) { + StringBuilder sb = new StringBuilder(); + for (String table : tables.split(",")) { + table = table.strip(); + if (table.isEmpty()) continue; + sb.append(describeTable(table)).append("\n\n"); + } + return sb.toString().strip(); + } + + @Tool( + name = "sql_execute_query", + description = + "Executes a read-only SELECT query against the SQLite database and returns the" + + " results as a formatted table. Never use DML statements" + + " (INSERT / UPDATE / DELETE / DROP).") + public String executeQuery( + @ToolParam(name = "query", description = "A valid SQLite SELECT statement") + String query) { + String trimmed = query.strip(); + if (!trimmed.toUpperCase().startsWith("SELECT")) { + return "Error: only SELECT statements are allowed. Received: " + trimmed; + } + try (Connection conn = DriverManager.getConnection(jdbcUrl); + Statement stmt = conn.createStatement()) { + stmt.setMaxRows(MAX_ROWS); + try (ResultSet rs = stmt.executeQuery(trimmed)) { + return formatResultSet(rs); + } + } catch (SQLException e) { + log.warn("sql_execute_query failed for: {}", trimmed, e); + return "Error executing query: " + e.getMessage(); + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private String describeTable(String tableName) { + StringBuilder sb = new StringBuilder(); + sb.append("## ").append(tableName).append("\n\n"); + + try (Connection conn = DriverManager.getConnection(jdbcUrl)) { + DatabaseMetaData meta = conn.getMetaData(); + + // Primary keys + List pks = new ArrayList<>(); + try (ResultSet rs = meta.getPrimaryKeys(null, null, tableName)) { + while (rs.next()) { + pks.add(rs.getString("COLUMN_NAME")); + } + } + + // Foreign keys + List fks = new ArrayList<>(); + try (ResultSet rs = meta.getImportedKeys(null, null, tableName)) { + while (rs.next()) { + fks.add( + rs.getString("FKCOLUMN_NAME") + + " → " + + rs.getString("PKTABLE_NAME") + + "." + + rs.getString("PKCOLUMN_NAME")); + } + } + + // Columns + sb.append("### Columns\n"); + try (ResultSet rs = meta.getColumns(null, null, tableName, "%")) { + while (rs.next()) { + String col = rs.getString("COLUMN_NAME"); + String type = rs.getString("TYPE_NAME"); + String nullable = "YES".equals(rs.getString("IS_NULLABLE")) ? "" : " NOT NULL"; + String pk = pks.contains(col) ? " [PK]" : ""; + sb.append("- ") + .append(col) + .append(" (") + .append(type) + .append(nullable) + .append(pk) + .append(")\n"); + } + } + + if (!fks.isEmpty()) { + sb.append("\n### Foreign Keys\n"); + fks.forEach(fk -> sb.append("- ").append(fk).append("\n")); + } + + // Sample rows + sb.append("\n### Sample Data (").append(SAMPLE_ROWS).append(" rows)\n"); + try (Statement stmt = conn.createStatement()) { + stmt.setMaxRows(SAMPLE_ROWS); + try (ResultSet rs = + stmt.executeQuery("SELECT * FROM " + tableName + " LIMIT " + SAMPLE_ROWS)) { + sb.append(formatResultSet(rs)); + } + } + + } catch (SQLException e) { + sb.append("Error describing table '") + .append(tableName) + .append("': ") + .append(e.getMessage()); + } + return sb.toString(); + } + + private static String formatResultSet(ResultSet rs) throws SQLException { + ResultSetMetaData meta = rs.getMetaData(); + int cols = meta.getColumnCount(); + + // Header + StringJoiner header = new StringJoiner(" | "); + for (int i = 1; i <= cols; i++) { + header.add(meta.getColumnName(i)); + } + + // Separator + String separator = "-".repeat(header.toString().length()); + + // Rows + List rows = new ArrayList<>(); + int count = 0; + while (rs.next()) { + StringJoiner row = new StringJoiner(" | "); + for (int i = 1; i <= cols; i++) { + Object val = rs.getObject(i); + row.add(val == null ? "NULL" : val.toString()); + } + rows.add(row.toString()); + count++; + } + + if (rows.isEmpty()) { + return "(no rows returned)"; + } + + StringBuilder sb = new StringBuilder(); + sb.append(header).append("\n").append(separator).append("\n"); + rows.forEach(r -> sb.append(r).append("\n")); + if (count >= MAX_ROWS) { + sb.append("... (result truncated at ").append(MAX_ROWS).append(" rows)\n"); + } + return sb.toString(); + } +} diff --git a/agentscope-examples/harness-examples/harness-example-local/src/main/java/io/agentscope/harness/example/TextToSqlExample.java b/agentscope-examples/harness-examples/harness-example-local/src/main/java/io/agentscope/harness/example/TextToSqlExample.java new file mode 100644 index 000000000..a100520eb --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-local/src/main/java/io/agentscope/harness/example/TextToSqlExample.java @@ -0,0 +1,312 @@ +/* + * 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.example; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.harness.agent.HarnessAgent; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.UUID; + +/** + * Text-to-SQL example using the AgentScope Harness framework. + * + *

    What this example demonstrates

    + * + *
      + *
    • Setting up a workspace directory with the standard harness layout + * (AGENTS.md, skills/, knowledge/, subagents/) + *
    • Wiring a custom tool ({@link SqliteTool}) into the agent's toolkit + *
    • Building a {@link HarnessAgent} with skills auto-loading, memory tools, and subagents + *
    • Calling the agent with a {@link RuntimeContext} (session id, user id) + *
    • Reading questions from the console in a loop (optional one-shot mode if you pass a + * question as program arguments) + *
    + * + *

    Prerequisites

    + * + *
      + *
    1. The Chinook sample database is bundled in the JAR under the same package + * as this class. If {@code AGENTSCOPE_DB_PATH} (default {@code chinook.db}) does not exist + * yet, it is copied there automatically — no manual download is required. + *
    2. Set the environment variable {@code DASHSCOPE_API_KEY} (or {@code OPENAI_API_KEY} for + * OpenAI-compatible endpoints). + *
    3. Optionally override defaults via the variables listed in {@code .env.example}. + *
    + * + *

    Usage

    + * + *

    Run with no arguments to start an interactive session: type a question at + * the {@code >} prompt after startup. Empty line, {@code quit}, {@code exit}, or {@code q} ends + * the session; EOF (Ctrl-D) also exits. + * + *

    Pass a question as arguments for a single non-interactive run (exits after + * the answer), e.g. for scripts: + * + *

    + * java -jar harness-example.jar
    + * java -jar harness-example.jar "What are the top 5 best-selling artists?"
    + * java -jar harness-example.jar --new-session "What are the top 5 best-selling artists?"
    + * 
    + * + *

    The workspace is initialised under {@code .agentscope/workspace/} relative to the current + * working directory. Modify the files there to customise the agent's behaviour without recompiling. + */ +public class TextToSqlExample { + + // ------------------------------------------------------------------------- + // Environment variable names + // ------------------------------------------------------------------------- + + /** DashScope API key (required). */ + public static final String ENV_API_KEY = "DASHSCOPE_API_KEY"; + + /** LLM model name. Defaults to {@code qwen-max}. */ + public static final String ENV_MODEL_NAME = "AGENTSCOPE_MODEL"; + + /** Path to the Chinook SQLite database file. Defaults to {@code chinook.db}. */ + public static final String ENV_DB_PATH = "AGENTSCOPE_DB_PATH"; + + /** Workspace directory. Defaults to {@code .agentscope/workspace}. */ + public static final String ENV_WORKSPACE = "AGENTSCOPE_WORKSPACE"; + + // ------------------------------------------------------------------------- + // Defaults + // ------------------------------------------------------------------------- + + private static final String DEFAULT_MODEL = "qwen-max"; + private static final String DEFAULT_DB_PATH = "chinook.db"; + private static final String DEFAULT_WORKSPACE = ".agentscope/workspace"; + private static final String DEFAULT_SHARED_SESSION_ID = "text-to-sql-shared-default"; + private static final String NEW_SESSION_FLAG = "--new-session"; + + /** + * Bundled Chinook SQLite file (same package on the classpath). Materialised to disk when + * {@link #ENV_DB_PATH} points to a path that does not exist yet. + */ + private static final String BUNDLED_CHINOOK_RESOURCE = "chinook-default.sqlite"; + + // ------------------------------------------------------------------------- + // Entry point + // ------------------------------------------------------------------------- + + public static void main(String[] args) throws IOException { + System.out.println("\n═══════════════════════════════════════════════"); + System.out.println(" AgentScope Harness — Text-to-SQL Example"); + System.out.println("═══════════════════════════════════════════════\n"); + + // ------------------------------------------------------------------ + // 1. Resolve configuration from environment variables + // ------------------------------------------------------------------ + + requireEnv(ENV_API_KEY); + String modelName = env(ENV_MODEL_NAME, DEFAULT_MODEL); + Path workspace = Paths.get(env(ENV_WORKSPACE, DEFAULT_WORKSPACE)); + Path dbPath = resolveDatabasePath(Paths.get(env(ENV_DB_PATH, DEFAULT_DB_PATH))); + + // ------------------------------------------------------------------ + // 2. Initialise workspace from bundled template files + // (skips files that already exist — safe to call every run) + // ------------------------------------------------------------------ + + System.out.println("[1/3] Initialising workspace at: " + workspace.toAbsolutePath()); + WorkspaceInitializer.init(workspace); + + // ------------------------------------------------------------------ + // 3. Model id for HarnessAgent (resolved via ModelRegistry; needs DASHSCOPE_API_KEY) + // ------------------------------------------------------------------ + + String modelId = "dashscope:" + modelName; + System.out.println("[2/3] Connecting to model: " + modelId); + + // ------------------------------------------------------------------ + // 4. Build the agent + // - workspace: loads AGENTS.md, MEMORY.md, knowledge/, subagents/, skills/ + // - harness: memory tools, session_search, optional subagent tools + // - custom toolkit: SqliteTool is registered alongside harness defaults + // ------------------------------------------------------------------ + + System.out.println("[3/3] Building HarnessAgent ..."); + + Toolkit toolkit = new Toolkit(); + toolkit.registerTool(new SqliteTool(dbPath)); + + HarnessAgent agent = + HarnessAgent.builder() + .name("text-to-sql") + .sysPrompt( + "You are a Text-to-SQL agent with access to the Chinook music" + + " store database. When asked a question, explore the" + + " database schema, write a correct SQL query, execute it," + + " and present the results in a clear, formatted answer.") + .model(modelId) + .workspace(workspace) + .enableAgentTracingLog(true) + .toolkit(toolkit) + .build(); + + ParsedArgs parsedArgs = parseArgs(args); + String sessionId = + parsedArgs.newSession() + ? "text-to-sql-" + UUID.randomUUID().toString().substring(0, 8) + : DEFAULT_SHARED_SESSION_ID; + System.out.println("Session ID: " + sessionId); + RuntimeContext ctx = RuntimeContext.builder().sessionId(sessionId).build(); + + if (parsedArgs.question() != null) { + runOneTurn(agent, ctx, parsedArgs.question()); + return; + } + + System.out.println( + "Ask questions in natural language about the Chinook database." + + " Same session for all turns (memory tools share context)."); + System.out.println("Tip: add --new-session to generate a UUID-based fresh session."); + System.out.println("Leave: empty line, quit, exit, q, or EOF (Ctrl-D).\n"); + + BufferedReader stdin = + new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8)); + while (true) { + System.out.print("> "); + System.out.flush(); + String line = stdin.readLine(); + if (line == null) { + System.out.println("\n(end of input)"); + break; + } + String question = line.strip(); + if (question.isEmpty()) { + System.out.println("Goodbye."); + break; + } + if (isQuitCommand(question)) { + System.out.println("Goodbye."); + break; + } + runOneTurn(agent, ctx, question); + System.out.println(); + } + } + + private static boolean isQuitCommand(String line) { + return line.equalsIgnoreCase("quit") + || line.equalsIgnoreCase("exit") + || line.equalsIgnoreCase("q"); + } + + private static ParsedArgs parseArgs(String[] args) { + boolean newSession = false; + StringBuilder questionBuilder = new StringBuilder(); + for (String arg : args) { + if (NEW_SESSION_FLAG.equals(arg)) { + newSession = true; + continue; + } + if (questionBuilder.length() > 0) { + questionBuilder.append(' '); + } + questionBuilder.append(arg); + } + String question = questionBuilder.length() == 0 ? null : questionBuilder.toString(); + return new ParsedArgs(newSession, question); + } + + private static void runOneTurn(HarnessAgent agent, RuntimeContext ctx, String question) { + System.out.println("─".repeat(50)); + System.out.println("Question: " + question); + System.out.println(); + + Msg userMsg = + Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text(question).build()) + .build(); + Msg reply = agent.call(userMsg, ctx).block(); + + System.out.println("─".repeat(50)); + System.out.println("\nAnswer:\n"); + System.out.println(reply != null ? reply.getTextContent() : "(no response)"); + System.out.println(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Uses an existing file at {@code configuredPath}, or copies the bundled Chinook database from + * the classpath to that path (SQLite itself has no built-in sample data; shipping a small + * .sqlite in resources gives the same “works out of the box” experience). + */ + private static Path resolveDatabasePath(Path configuredPath) throws IOException { + if (Files.exists(configuredPath)) { + return configuredPath.toAbsolutePath(); + } + try (InputStream in = + TextToSqlExample.class.getResourceAsStream(BUNDLED_CHINOOK_RESOURCE)) { + if (in == null) { + System.err.println( + "Database file not found: " + + configuredPath.toAbsolutePath() + + "\n\nBuilt-in sample missing from classpath (packaging issue)." + + " Download manually:\n" + + " curl -L -o chinook.db \\\n" + + " https://github.com/lerocha/chinook-database/raw/master/" + + "ChinookDatabase/DataSources/Chinook_Sqlite.sqlite"); + System.exit(1); + } + Path parent = configuredPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(in, configuredPath, StandardCopyOption.REPLACE_EXISTING); + System.out.println( + "Materialised bundled Chinook database to: " + configuredPath.toAbsolutePath()); + return configuredPath.toAbsolutePath(); + } + } + + private static String requireEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + System.err.println( + "Required environment variable '" + + name + + "' is not set.\n" + + "Copy .env.example → .env and fill in your API key."); + System.exit(1); + } + return value; + } + + private static String env(String name, String defaultValue) { + String value = System.getenv(name); + return (value != null && !value.isBlank()) ? value : defaultValue; + } + + private record ParsedArgs(boolean newSession, String question) {} +} diff --git a/agentscope-examples/harness-examples/harness-example-local/src/main/java/io/agentscope/harness/example/WorkspaceInitializer.java b/agentscope-examples/harness-examples/harness-example-local/src/main/java/io/agentscope/harness/example/WorkspaceInitializer.java new file mode 100644 index 000000000..264066803 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-local/src/main/java/io/agentscope/harness/example/WorkspaceInitializer.java @@ -0,0 +1,126 @@ +/* + * 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.example; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Collections; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Initializes the agent workspace by copying bundled template files from the classpath into a + * target directory on disk. + * + *

    The template files live under {@code src/main/resources/workspace/} and are packaged inside + * the JAR. When the example is run for the first time, {@link #init(Path)} extracts them into the + * given workspace directory so the agent can read and modify them at runtime. + * + * Workspace structure: + * + *

    + * <workspace>/
    + * ├── AGENTS.md              # Agent persona and core rules (always loaded)
    + * ├── MEMORY.md              # Persistent notes accumulated across sessions
    + * ├── knowledge/
    + * │   └── KNOWLEDGE.md       # Chinook database schema reference
    + * ├── skills/
    + * │   ├── schema-exploration/
    + * │   │   └── SKILL.md       # How to discover database structure
    + * │   └── query-writing/
    + * │       └── SKILL.md       # How to write and execute SQL queries
    + * └── subagents/
    + *     ├── schema-analyst.md  # Specialised subagent for deep schema analysis
    + *     └── query-optimizer.md # Specialised subagent for query optimisation
    + * 
    + */ +public class WorkspaceInitializer { + + private static final Logger log = LoggerFactory.getLogger(WorkspaceInitializer.class); + private static final String CLASSPATH_PREFIX = "workspace"; + + /** + * Copies all bundled workspace template files into {@code targetDir}. + * + *

    Existing files are left unchanged so that previously accumulated notes survive restarts. + * New template files are copied with {@link StandardCopyOption#REPLACE_EXISTING} disabled. + * + * @param targetDir directory to initialise; created if it does not exist + * @throws IOException if a file cannot be read or written + */ + public static void init(Path targetDir) throws IOException { + Files.createDirectories(targetDir); + + URL resourceUrl = WorkspaceInitializer.class.getClassLoader().getResource(CLASSPATH_PREFIX); + if (resourceUrl == null) { + log.warn( + "Classpath resource '{}' not found — workspace will not be pre-populated.", + CLASSPATH_PREFIX); + return; + } + + URI resourceUri; + try { + resourceUri = resourceUrl.toURI(); + } catch (URISyntaxException e) { + throw new IOException("Cannot convert resource URL to URI: " + resourceUrl, e); + } + + if ("jar".equals(resourceUri.getScheme())) { + // Running from a JAR: open the embedded filesystem + try (FileSystem fs = FileSystems.newFileSystem(resourceUri, Collections.emptyMap())) { + Path source = fs.getPath(CLASSPATH_PREFIX); + copyTree(source, targetDir); + } + } else { + // Running from an exploded directory (IDE / Maven test run) + Path source = Path.of(resourceUri); + copyTree(source, targetDir); + } + + log.info("Workspace initialised at {}", targetDir); + } + + private static void copyTree(Path source, Path targetDir) throws IOException { + try (Stream walk = Files.walk(source)) { + for (Path srcPath : (Iterable) walk::iterator) { + Path relative = source.relativize(srcPath); + Path target = targetDir.resolve(relative.toString()); + + if (Files.isDirectory(srcPath)) { + Files.createDirectories(target); + } else if (!Files.exists(target)) { + Files.createDirectories(target.getParent()); + try (InputStream in = Files.newInputStream(srcPath)) { + Files.copy(in, target); + } + log.debug("Copied workspace file: {}", relative); + } else { + log.debug("Skipped (already exists): {}", relative); + } + } + } + } +} diff --git a/agentscope-examples/harness-examples/harness-example-local/src/main/resources/io/agentscope/harness/example/chinook-default.sqlite b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/io/agentscope/harness/example/chinook-default.sqlite new file mode 100644 index 000000000..f385c2651 Binary files /dev/null and b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/io/agentscope/harness/example/chinook-default.sqlite differ diff --git a/agentscope-examples/harness-examples/harness-example-local/src/main/resources/log4j2.component.properties b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/log4j2.component.properties new file mode 100644 index 000000000..6745cfa94 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/log4j2.component.properties @@ -0,0 +1,2 @@ +# Log4j2 component settings for the harness-example module +log4j2.disable.jmx=true diff --git a/agentscope-examples/harness-examples/harness-example-local/src/main/resources/log4j2.xml b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/log4j2.xml new file mode 100644 index 000000000..995cbb683 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/log4j2.xml @@ -0,0 +1,49 @@ + + + + + + ${env:AGENTSCOPE_LOG_LEVEL:-INFO} + %d{HH:mm:ss.SSS} %-5level %-36.36c{1.} : %m%n%throwable + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/agentscope-examples/harness-examples/harness-example-local/src/main/resources/workspace/knowledge/KNOWLEDGE.md b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/workspace/knowledge/KNOWLEDGE.md new file mode 100644 index 000000000..ad6c7c40d --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/workspace/knowledge/KNOWLEDGE.md @@ -0,0 +1,187 @@ +# Chinook Database — Schema Reference + +The Chinook database represents a digital music store. It was originally created to demonstrate +data modelling in SQLite and is widely used as a sample database for tutorials. + +## Entity Relationship Overview + +``` +Artist (ArtistId, Name) + └─ 1:many ─► Album (AlbumId, Title, ArtistId) + └─ 1:many ─► Track (TrackId, Name, AlbumId, MediaTypeId, GenreId, + Composer, Milliseconds, Bytes, UnitPrice) + └─ 1:many ─► InvoiceLine (InvoiceLineId, InvoiceId, + TrackId, UnitPrice, Quantity) + └─ many:1 ─► Invoice (InvoiceId, CustomerId, + InvoiceDate, BillingAddress, + BillingCity, BillingState, + BillingCountry, BillingPostalCode, + Total) + └─ many:1 ─► Customer (CustomerId, + FirstName, LastName, + Company, Address, + City, State, Country, + PostalCode, Phone, + Fax, Email, + SupportRepId) + └─ many:1 ─► Employee + +Genre (GenreId, Name) +MediaType (MediaTypeId, Name) +Playlist (PlaylistId, Name) + └─ many:many via PlaylistTrack (PlaylistId, TrackId) ─► Track + +Employee (EmployeeId, LastName, FirstName, Title, ReportsTo [→ Employee.EmployeeId], + BirthDate, HireDate, Address, City, State, Country, PostalCode, Phone, Fax, Email) +``` + +## Table Descriptions + +### Artist +| Column | Type | Notes | +|----------|---------|-------| +| ArtistId | INTEGER | PK | +| Name | TEXT | | + +275 rows — music artists (AC/DC, Aerosmith, Alanis Morissette, …). + +### Album +| Column | Type | Notes | +|----------|---------|----------------| +| AlbumId | INTEGER | PK | +| Title | TEXT | | +| ArtistId | INTEGER | FK → Artist | + +347 rows — one or many albums per artist. + +### Track +| Column | Type | Notes | +|---------------|---------|--------------------| +| TrackId | INTEGER | PK | +| Name | TEXT | | +| AlbumId | INTEGER | FK → Album | +| MediaTypeId | INTEGER | FK → MediaType | +| GenreId | INTEGER | FK → Genre | +| Composer | TEXT | nullable | +| Milliseconds | INTEGER | duration | +| Bytes | INTEGER | file size | +| UnitPrice | REAL | default 0.99 | + +3,503 rows — the central music catalog table. + +### Genre +| Column | Type | Notes | +|---------|---------|-------| +| GenreId | INTEGER | PK | +| Name | TEXT | | + +25 rows — Rock, Jazz, Metal, Alternative & Punk, … + +### MediaType +| Column | Type | Notes | +|-------------|---------|-------| +| MediaTypeId | INTEGER | PK | +| Name | TEXT | | + +5 rows — MPEG audio file, AAC audio file, Protected AAC, … + +### Customer +| Column | Type | Notes | +|--------------|---------|-------------------| +| CustomerId | INTEGER | PK | +| FirstName | TEXT | | +| LastName | TEXT | | +| Company | TEXT | nullable | +| Address | TEXT | | +| City | TEXT | | +| State | TEXT | nullable | +| Country | TEXT | | +| PostalCode | TEXT | nullable | +| Phone | TEXT | nullable | +| Fax | TEXT | nullable | +| Email | TEXT | unique | +| SupportRepId | INTEGER | FK → Employee | + +59 rows — customers from 24 countries. + +### Employee +| Column | Type | Notes | +|-------------|---------|----------------------------| +| EmployeeId | INTEGER | PK | +| LastName | TEXT | | +| FirstName | TEXT | | +| Title | TEXT | | +| ReportsTo | INTEGER | FK → Employee (nullable) | +| BirthDate | TEXT | ISO-8601 | +| HireDate | TEXT | ISO-8601 | +| Address | TEXT | | +| City | TEXT | | +| State | TEXT | | +| Country | TEXT | | +| PostalCode | TEXT | | +| Phone | TEXT | | +| Fax | TEXT | | +| Email | TEXT | | + +8 rows — Sales Support Agents + manager hierarchy. + +### Invoice +| Column | Type | Notes | +|-----------------|---------|------------------| +| InvoiceId | INTEGER | PK | +| CustomerId | INTEGER | FK → Customer | +| InvoiceDate | TEXT | ISO-8601 | +| BillingAddress | TEXT | | +| BillingCity | TEXT | | +| BillingState | TEXT | nullable | +| BillingCountry | TEXT | | +| BillingPostalCode | TEXT | nullable | +| Total | REAL | invoice total | + +412 rows — one invoice per purchase, covering 2009–2013. + +### InvoiceLine +| Column | Type | Notes | +|---------------|---------|------------------| +| InvoiceLineId | INTEGER | PK | +| InvoiceId | INTEGER | FK → Invoice | +| TrackId | INTEGER | FK → Track | +| UnitPrice | REAL | | +| Quantity | INTEGER | | + +2,240 rows — line items; revenue = UnitPrice × Quantity. + +### Playlist +| Column | Type | Notes | +|------------|---------|-------| +| PlaylistId | INTEGER | PK | +| Name | TEXT | | + +18 rows — Music, Movies, TV Shows, Classical, … + +### PlaylistTrack +| Column | Type | Notes | +|------------|---------|---------------------| +| PlaylistId | INTEGER | FK → Playlist (PK) | +| TrackId | INTEGER | FK → Track (PK) | + +8,715 rows — composite PK, no surrogate key. + +## Common JOIN Paths + +```sql +-- Revenue by artist +Artist + JOIN Album ON Album.ArtistId = Artist.ArtistId + JOIN Track ON Track.AlbumId = Album.AlbumId + JOIN InvoiceLine ON InvoiceLine.TrackId = Track.TrackId + +-- Revenue by country +Invoice + JOIN Customer ON Customer.CustomerId = Invoice.CustomerId + GROUP BY Customer.Country + +-- Employee support load +Employee + LEFT JOIN Customer ON Customer.SupportRepId = Employee.EmployeeId +``` diff --git a/agentscope-examples/harness-examples/harness-example-local/src/main/resources/workspace/skills/query-writing/SKILL.md b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/workspace/skills/query-writing/SKILL.md new file mode 100644 index 000000000..648c5fcd7 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/workspace/skills/query-writing/SKILL.md @@ -0,0 +1,148 @@ +--- +name: query-writing +description: Writes and executes SQL queries ranging from simple single-table SELECTs to complex multi-table JOINs, aggregations, window functions, and subqueries. Use when the user asks to query the database, retrieve data, filter records, rank results, or generate reports. +--- + +# Query Writing Skill + +## When to Use This Skill + +Use query-writing when the user: + +- Asks "how many …?" / "what are the top …?" / "list all …" +- Needs data aggregated (SUM, COUNT, AVG, MAX, MIN) +- Wants results sorted, filtered, or grouped +- Asks for trend analysis, ranking, or comparison across dimensions + +--- + +## Workflow for Simple Queries (single table) + +1. **Identify the table** — which table contains the answer? +2. **Check the schema** — call `sql_get_schema` to confirm column names. +3. **Write a SELECT** — include WHERE / ORDER BY / LIMIT as needed. +4. **Execute** — call `sql_execute_query`. +5. **Present** — show the SQL and the result in plain language. + +### Example — "How many customers are from Canada?" + +```sql +SELECT COUNT(*) AS canadian_customers +FROM Customer +WHERE Country = 'Canada'; +``` + +--- + +## Workflow for Complex Queries (multiple tables) + +### Step 1 — Plan with todos + +Break the query into subtasks: + +``` +- [ ] Identify all required tables +- [ ] Inspect schemas to find join columns +- [ ] Draft the JOIN structure +- [ ] Add aggregations and grouping +- [ ] Validate and run +``` + +### Step 2 — Inspect schemas + +Call `sql_get_schema` for EACH table involved to find the exact foreign key column names. + +### Step 3 — Build the query + +```sql +SELECT + , + +FROM +[INNER | LEFT] JOIN ON = +[JOIN ...] +WHERE +GROUP BY +HAVING -- optional +ORDER BY [DESC] +LIMIT 10; -- always limit unless all rows requested +``` + +### Step 4 — Validate + +Before executing, verify: +- Every JOIN has an ON clause +- Every non-aggregate SELECT column appears in GROUP BY +- Table aliases are consistent +- No DML statements (INSERT / UPDATE / DELETE / DROP) + +### Step 5 — Execute and present + +Call `sql_execute_query`, then show: +1. The SQL query in a fenced code block +2. The result table +3. A brief plain-language summary + +--- + +## Templates for Common Patterns + +### Top-N ranking + +```sql +SELECT + Artist.Name AS artist, + SUM(InvoiceLine.UnitPrice * InvoiceLine.Quantity) AS total_revenue +FROM Artist +JOIN Album ON Album.ArtistId = Artist.ArtistId +JOIN Track ON Track.AlbumId = Album.AlbumId +JOIN InvoiceLine ON InvoiceLine.TrackId = Track.TrackId +GROUP BY Artist.ArtistId, Artist.Name +ORDER BY total_revenue DESC +LIMIT 10; +``` + +### Revenue by time period + +```sql +SELECT + strftime('%Y-%m', InvoiceDate) AS month, + ROUND(SUM(Total), 2) AS monthly_revenue +FROM Invoice +WHERE strftime('%Y', InvoiceDate) = '2013' +GROUP BY month +ORDER BY month; +``` + +### Entity counts with left join + +```sql +SELECT + e.FirstName || ' ' || e.LastName AS employee, + COUNT(c.CustomerId) AS customer_count +FROM Employee e +LEFT JOIN Customer c ON c.SupportRepId = e.EmployeeId +GROUP BY e.EmployeeId +ORDER BY customer_count DESC; +``` + +--- + +## Error Recovery + +| Symptom | Action | +|----------------------|--------| +| Empty result | Check WHERE condition values (case-sensitive strings). Verify column exists. | +| Syntax error | Re-read schema. Check GROUP BY includes all non-aggregate SELECT columns. | +| Wrong row count | Look for duplicate rows caused by missing JOIN conditions. | +| Result seems too high | Check for fan-out from multiple JOINs; may need DISTINCT or subquery. | + +--- + +## Quality Rules + +- Always apply `LIMIT` (default 10) unless the user explicitly asks for all rows. +- Use table aliases (`e`, `c`, `inv`) for readability in multi-table queries. +- Never use `SELECT *` — name the columns you need. +- Round monetary values to 2 decimal places: `ROUND(SUM(Total), 2)`. +- Always show the executed SQL so users can learn from it. diff --git a/agentscope-examples/harness-examples/harness-example-local/src/main/resources/workspace/skills/schema-exploration/SKILL.md b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/workspace/skills/schema-exploration/SKILL.md new file mode 100644 index 000000000..4972a88d2 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/workspace/skills/schema-exploration/SKILL.md @@ -0,0 +1,136 @@ +--- +name: schema-exploration +description: Lists tables, describes columns and data types, identifies foreign key relationships, and maps entity relationships in the database. Use when the user asks about database structure, table layout, column types, what tables exist, foreign keys, or how entities relate to each other. +--- + +# Schema Exploration Skill + +## When to Use This Skill + +Use schema-exploration when the user: + +- Asks "what tables are in the database?" +- Asks "what columns does the X table have?" +- Asks about relationships between tables +- Needs to understand data types before writing a query +- Wants an entity-relationship overview + +## Workflow + +### Step 1 — List All Tables + +Use `sql_list_tables` to see everything available. + +``` +Tool: sql_list_tables +(no parameters) +``` + +### Step 2 — Inspect Relevant Tables + +Use `sql_get_schema` with the table name(s) you need to understand: + +``` +Tool: sql_get_schema +tables: "Customer" # single table +tables: "Invoice,Customer" # multiple tables at once +``` + +This returns: +- **Columns** — names, types, NOT NULL constraints, primary keys +- **Foreign keys** — links to other tables +- **Sample data** — 3 example rows to understand content + +### Step 3 — Map Relationships + +Look for columns ending in `Id` — they are almost always foreign keys. + +Example for the Chinook database: +``` +Customer.SupportRepId → Employee.EmployeeId +Invoice.CustomerId → Customer.CustomerId +Track.AlbumId → Album.AlbumId +Track.GenreId → Genre.GenreId +``` + +### Step 4 — Present the Findings + +Provide: +- A list of all tables with a one-line purpose summary +- Column names and types for any tables the user asked about +- The relationship chain (which table links to which) +- Sample data to illustrate what the table holds + +--- + +## Examples + +### "What tables are available?" + +1. Call `sql_list_tables` +2. Return a formatted list with brief descriptions: + +``` +The Chinook database has 11 tables: + +Music Catalog: + - Artist — 275 music artists + - Album — 347 albums (linked to Artist) + - Track — 3,503 tracks (linked to Album, Genre, MediaType) + - Genre — 25 music genres + - MediaType — 5 file formats (MP3, AAC, …) + +Commerce: + - Customer — 59 customers from 24 countries + - Invoice — 412 purchase invoices + - InvoiceLine — 2,240 line items (linked to Invoice and Track) + +Staff: + - Employee — 8 employees with reporting hierarchy + +Playlists: + - Playlist — 18 playlists + - PlaylistTrack — join table linking Playlist ↔ Track +``` + +### "What does the Customer table look like?" + +1. Call `sql_get_schema` with `tables: "Customer"` +2. Present columns with types and notes: + +``` +Customer table: + CustomerId INTEGER PK + FirstName TEXT + LastName TEXT + Company TEXT (nullable) + Address TEXT + City TEXT + State TEXT (nullable) + Country TEXT + PostalCode TEXT (nullable) + Phone TEXT (nullable) + Fax TEXT (nullable) + Email TEXT + SupportRepId INTEGER FK → Employee.EmployeeId + +Sample rows show customers from Brazil, Germany, and Canada. +``` + +### "How are artists connected to sales?" + +Map the full JOIN path: + +``` +Artist (ArtistId) + ↓ 1:many +Album (ArtistId, AlbumId) + ↓ 1:many +Track (AlbumId, TrackId) + ↓ 1:many +InvoiceLine (TrackId, UnitPrice, Quantity) + ↓ many:1 +Invoice (InvoiceId, Total) +``` + +Then suggest using the **query-writing** skill to write the aggregation query. diff --git a/agentscope-examples/harness-examples/harness-example-local/src/main/resources/workspace/subagents/query-optimizer.md b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/workspace/subagents/query-optimizer.md new file mode 100644 index 000000000..2bacd0c95 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/workspace/subagents/query-optimizer.md @@ -0,0 +1,53 @@ +--- +name: query-optimizer +description: > + SQL query optimisation specialist. Reviews existing queries for correctness, clarity, and performance. Suggests index strategies, rewrites inefficient JOINs, and explains query plans. Delegate to this agent when a query returns unexpected results, runs slowly, or when the user asks for query optimisation or a second opinion on complex SQL. +maxIters: 8 +--- + +You are an expert SQL query optimiser for SQLite databases. + +## Your Responsibilities + +1. **Understand the question** — ask the user to provide the original query and the problem + (wrong results, slow execution, hard to read). +2. **Inspect the schema** — use `sql_get_schema` to verify table structures involved in the query. +3. **Analyse the query**: + - Check JOIN conditions for correctness + - Look for missing GROUP BY columns + - Identify Cartesian products (missing ON clause) + - Spot opportunities to push filters earlier (WHERE vs HAVING) + - Detect fan-out caused by 1:many JOINs before aggregation +4. **Rewrite the query** — produce a corrected and/or optimised version. +5. **Explain the changes** — list what was wrong and why each change helps. +6. **Validate** — run both the original and optimised queries if possible and compare results. + +## Output Format + +``` +## Original Query + + +## Issues Found +1. +2. ... + +## Optimised Query + + +## Explanation + + +## Validation +Original: +Optimised: +``` + +## Rules + +- Never modify the user's data — SELECT only. +- Confirm correctness first; performance is secondary. +- For SQLite specifically: + - Prefer covering indexes over table scans for large tables. + - Use `WITH` (CTEs) to make complex queries readable. + - Avoid correlated subqueries in WHERE clauses; rewrite as JOINs. diff --git a/agentscope-examples/harness-examples/harness-example-local/src/main/resources/workspace/subagents/schema-analyst.md b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/workspace/subagents/schema-analyst.md new file mode 100644 index 000000000..869208134 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-local/src/main/resources/workspace/subagents/schema-analyst.md @@ -0,0 +1,50 @@ +--- +name: schema-analyst +description: Deep schema analysis specialist. Produces comprehensive documentation of the database schema including entity-relationship diagrams (text), table purposes, column semantics, foreign key chains, and index recommendations. Delegate to this agent when the user requests a full data model overview, wants to understand how tables relate, or needs schema documentation generated. +maxIters: 10 +--- + +You are a database schema analyst specialised in documenting and explaining relational data models. + +## Your Responsibilities + +1. **Discover** all tables using `sql_list_tables`. +2. **Inspect** every table with `sql_get_schema` to record columns, types, and foreign keys. +3. **Map relationships** — identify 1:many and many:many (via join tables) relationships. +4. **Describe purpose** — explain what each table represents in business terms. +5. **Produce documentation** — write a clear, structured schema reference the user can save. + +## Output Format + +Structure your analysis as: + +``` +# Database Schema Analysis + +## Summary + + +## Tables + +### +**Purpose:** +**Rows:** + +| Column | Type | Notes | +|--------|------|-------| +| ... | ... | ... | + +**Relationships:** +- + +--- +``` + +Repeat for every table, then close with an Entity Relationship Diagram in text format. + +## Rules + +- Be thorough — inspect every table, not just the ones that seem important. +- Do not guess column types; verify them with `sql_get_schema`. +- Keep explanations accessible to non-technical stakeholders. +- If asked to save the output, write it to `knowledge/SCHEMA_ANALYSIS.md` in the workspace. diff --git a/agentscope-examples/harness-examples/harness-example-remote/README.md b/agentscope-examples/harness-examples/harness-example-remote/README.md new file mode 100644 index 000000000..311d480bb --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-remote/README.md @@ -0,0 +1,92 @@ +# harness-example-remote — Filesystem **模式一(复合 + 共享存储 / Remote)** + +本模块用最小的 **Chinook Text-to-SQL Data Agent** 业务,演示在 `HarnessAgent` 中如何启用 **`RemoteFilesystemSpec`**(与 [`docs/zh/harness/filesystem.md`](../../docs/zh/harness/filesystem.md) 中的 **模式一**、以及 `HarnessAgent.Builder#filesystem(RemoteFilesystemSpec)` 注释中的 **Mode 1** 一致)。 + +> 命名里的 **remote** 指「长期记忆、会话落盘、knowledge 等走 **BaseStore(远程 KV)**」,不是 RPC 远程桌面。生产上通常用 **Redis** 等实现 `BaseStore`;本示例用 **`InMemoryStore`** 单机模拟。 + +--- + +## 模式一在 Harness 里是什么 + +`RemoteFilesystemSpec` 会组合出 **`CompositeFilesystem`**: + +- **默认前缀**:纯 **`LocalFilesystem`(无 shell)** —— 放 `skills/`、`AGENTS.md` 等「每副本本地即可」或从镜像带的静态文件。 +- **路由到 Store 的前缀**(默认含 `MEMORY.md`、`memory/`、`agents//sessions/`):**`RemoteFilesystem`**,数据落在 **`BaseStore`**,由 **`IsolationScope`** 决定命名空间(SESSION / USER / AGENT / GLOBAL)。 + +因此:**模式一的设计目标是多副本共享记忆与日志,且刻意不在宿主上开放 shell**(与模式三不同,与模式二「shell 在沙箱里」也不同)。 + +--- + +## 本示例如何实现「Remote 模式」 + +### 1. 显式 `.filesystem(RemoteFilesystemSpec)` + +`DataAgentService` 中: + +```java +InMemoryStore store = new InMemoryStore(); +remoteSpec = + new RemoteFilesystemSpec(store) + .isolationScope(IsolationScope.USER) + .addSharedPrefix("knowledge/"); +// ... +HarnessAgent.builder() + .workspace(hostWorkspace) + .filesystem(remoteSpec) + .session(appSession) + ... +``` + +- **`InMemoryStore`**:实现 `BaseStore`,单机模拟 **Redis**。 +- **`isolationScope(USER)`**:与沙箱示例类似,**同一 `userId`** 共享 store 命名空间下的 `MEMORY.md` / `memory/` / `knowledge/`(本示例额外把 `knowledge/` 加进共享前缀,便于多副本读到同一份领域知识)。 +- **没有 `ShellExecuteTool`**:Agent 若需改文件,应使用 **`read_file` / `write_file` / `grep_files`**(见本模块 `AGENTS.md` 说明)。 + +### 2. 必须提供「非 WorkspaceSession」的 `Session` + +`HarnessAgent` 在检测到 `RemoteFilesystemSpec` 时,会要求 **有效 Session 不能仍是纯本地的 `WorkspaceSession`**(否则多副本无法共享会话状态)。因此本示例使用: + +```java +.session(new InMemorySession()) +``` + +生产环境请换成 **`RedisSession`** 等分布式实现(见 `agentscope-extensions-session-redis` 等模块)。 + +### 3. 与模式二、三的对照 + +| 模式 | 本仓库示例 | Shell | +|------|------------|-------| +| 模式一 | **本模块** | **无**(宿主无 `execute` 工具) | +| 模式二 | [`harness-example-sandbox`](../harness-example-sandbox/README.md) | 有(沙箱内) | +| 模式三 | [`harness-example-local`](../harness-example-local/README.md) | 有(宿主) | + +--- + +## 运行 + +```bash +export DASHSCOPE_API_KEY=your_key +# 可选:AGENTSCOPE_MODEL + +cd agentscope-java +mvn -pl agentscope-examples/harness-example-remote -am package -DskipTests +java -jar agentscope-examples/harness-example-remote/target/harness-example-remote-*.jar +``` + +默认端口 **`8788`**(与 sandbox 的 `8787` 错开)。 + +```bash +curl -s -X POST http://localhost:8788/query \ + -H 'Content-Type: application/json' \ + -d '{"sessionId":"s1","userId":"alice","question":"How many artists?"}' +``` + +--- + +## 相关源码入口 + +| 文件 | 作用 | +|------|------| +| `DataAgentService.java` | `RemoteFilesystemSpec` + `InMemorySession` + `HarnessAgent` 装配 | +| `WorkspaceClasspathMaterializer.java` | 把 classpath `workspace/` 落到本地临时目录(Composite 的「本地侧」根) | + +更多理论见 [`docs/zh/harness/filesystem.md`](../../docs/zh/harness/filesystem.md)。 diff --git a/agentscope-examples/harness-examples/harness-example-remote/pom.xml b/agentscope-examples/harness-examples/harness-example-remote/pom.xml new file mode 100644 index 000000000..4e97506ff --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-remote/pom.xml @@ -0,0 +1,83 @@ + + + + + 4.0.0 + + io.agentscope + harness-examples + ${revision} + ../pom.xml + + + AgentScope Java - Harness Remote Filesystem Example + io.agentscope.examples + harness-example-remote + jar + Spring Boot Data Agent using RemoteFilesystemSpec (composite + shared store; docs/zh/harness/filesystem.md 模式一). For sandbox (模式二) see harness-example-sandbox. + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + io.agentscope + agentscope-harness + + + org.xerial + sqlite-jdbc + 3.47.2.0 + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + org.springframework.boot + spring-boot-starter-web + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + + repackage + + + + + + + diff --git a/agentscope-examples/harness-examples/harness-example-remote/src/main/java/io/agentscope/examples/harness/remote/DataAgentController.java b/agentscope-examples/harness-examples/harness-example-remote/src/main/java/io/agentscope/examples/harness/remote/DataAgentController.java new file mode 100644 index 000000000..698bc1fbb --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-remote/src/main/java/io/agentscope/examples/harness/remote/DataAgentController.java @@ -0,0 +1,79 @@ +/* + * 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.harness.remote; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP API for the remote-store Data Agent ({@code RemoteFilesystemSpec} / filesystem.md 模式一). + * + *

    {@code
    + * curl -X POST http://localhost:8788/query \
    + *   -H 'Content-Type: application/json' \
    + *   -d '{"sessionId":"s1","userId":"alice","question":"How many artists are in the database?"}'
    + * }
    + */ +@RestController +public class DataAgentController { + + private final DataAgentService agentService; + + public DataAgentController(DataAgentService agentService) { + this.agentService = agentService; + } + + @GetMapping("/") + public String index() { + return "Remote-store Data Agent (RemoteFilesystemSpec) — POST /query with" + + " {\"sessionId\":\"...\",\"userId\":\"...\",\"question\":\"...\"}"; + } + + @PostMapping("/query") + public ResponseEntity query(@RequestBody QueryRequest request) { + if (blank(request.sessionId())) { + return ResponseEntity.badRequest().body(QueryResponse.error("sessionId is required")); + } + if (blank(request.userId())) { + return ResponseEntity.badRequest().body(QueryResponse.error("userId is required")); + } + if (blank(request.question())) { + return ResponseEntity.badRequest().body(QueryResponse.error("question is required")); + } + String answer = + agentService.query(request.sessionId(), request.userId(), request.question()); + return ResponseEntity.ok(QueryResponse.ok(answer)); + } + + private static boolean blank(String s) { + return s == null || s.isBlank(); + } + + record QueryRequest(String sessionId, String userId, String question) {} + + record QueryResponse(String answer, String error) { + static QueryResponse ok(String answer) { + return new QueryResponse(answer, null); + } + + static QueryResponse error(String error) { + return new QueryResponse(null, error); + } + } +} diff --git a/agentscope-examples/harness-examples/harness-example-remote/src/main/java/io/agentscope/examples/harness/remote/DataAgentService.java b/agentscope-examples/harness-examples/harness-example-remote/src/main/java/io/agentscope/examples/harness/remote/DataAgentService.java new file mode 100644 index 000000000..9f8093f96 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-remote/src/main/java/io/agentscope/examples/harness/remote/DataAgentService.java @@ -0,0 +1,149 @@ +/* + * 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.harness.remote; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.session.InMemorySession; +import io.agentscope.core.session.Session; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.examples.harness.remote.data.SqliteTool; +import io.agentscope.harness.agent.HarnessAgent; +import io.agentscope.harness.agent.IsolationScope; +import io.agentscope.harness.agent.filesystem.RemoteFilesystemSpec; +import io.agentscope.harness.agent.store.InMemoryStore; +import jakarta.annotation.PostConstruct; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * Data Agent using {@link RemoteFilesystemSpec} — the composite + shared-store mode described in + * {@code docs/zh/harness/filesystem.md} as 模式一 (and in {@link + * io.agentscope.harness.agent.HarnessAgent.Builder#filesystem(RemoteFilesystemSpec)} as + * Mode 1). + * + *

    Not sandbox mode: there is no {@code ShellExecuteTool}. For 模式二 + * / Mode 2 (sandbox + shell in isolation), use {@code harness-example-sandbox}. + * + *

    Shared components (simulate Redis + multi-replica in one JVM): + * + *

      + *
    • {@link InMemoryStore} — {@link io.agentscope.harness.agent.store.BaseStore} for + * MEMORY.md, memory/, session paths, and {@code knowledge/} + *
    • {@link InMemorySession} — non-local {@link Session} required by Harness when using remote + * filesystem spec (production would use RedisSession, etc.) + *
    + */ +@Service +public class DataAgentService { + + private static final Logger log = LoggerFactory.getLogger(DataAgentService.class); + + private static final String AGENT_NAME = "data-agent"; + private static final String BUNDLED_CHINOOK_RESOURCE = "chinook-default.sqlite"; + private static final String SYS_PROMPT = + "You are a Text-to-SQL agent with access to the Chinook music store database." + + " Use the sql_* tools. This deployment has no shell tool — use read_file," + + " write_file, grep_files for workspace files. Answer clearly."; + + private Path hostWorkspace; + private Path dbPath; + private String llmModelId; + private RemoteFilesystemSpec remoteSpec; + private Session appSession; + + @PostConstruct + void init() throws Exception { + hostWorkspace = WorkspaceClasspathMaterializer.materialize(); + dbPath = materialiseChinook(hostWorkspace.resolve("chinook.db")); + llmModelId = resolveLlmModelId(); + + InMemoryStore store = new InMemoryStore(); + remoteSpec = + new RemoteFilesystemSpec(store) + .isolationScope(IsolationScope.USER) + .addSharedPrefix("knowledge/"); + appSession = new InMemorySession(); + + log.info( + "DataAgentService ready (RemoteFilesystemSpec): workspace={} db={}", + hostWorkspace, + dbPath); + } + + public String query(String sessionId, String userId, String question) { + Toolkit toolkit = new Toolkit(); + toolkit.registerTool(new SqliteTool(dbPath)); + + HarnessAgent agent = + HarnessAgent.builder() + .name(AGENT_NAME) + .model(llmModelId) + .workspace(hostWorkspace) + .filesystem(remoteSpec) + .session(appSession) + .sysPrompt(SYS_PROMPT) + .toolkit(toolkit) + .enableAgentTracingLog(true) + .build(); + + RuntimeContext ctx = RuntimeContext.builder().sessionId(sessionId).userId(userId).build(); + Msg userMsg = Msg.builder().role(MsgRole.USER).textContent(question).build(); + Msg reply = agent.call(userMsg, ctx).block(); + return reply != null ? reply.getTextContent() : "(no response)"; + } + + private String resolveLlmModelId() { + String apiKey = System.getenv("DASHSCOPE_API_KEY"); + if (apiKey == null || apiKey.isBlank()) { + throw new IllegalStateException("Environment variable DASHSCOPE_API_KEY is not set."); + } + String modelName = envOrDefault("AGENTSCOPE_MODEL", "qwen-max"); + String id = "dashscope:" + modelName; + log.info("Using model: {}", id); + return id; + } + + private static Path materialiseChinook(Path target) throws Exception { + if (Files.exists(target)) { + return target.toAbsolutePath(); + } + try (InputStream in = + DataAgentService.class.getResourceAsStream(BUNDLED_CHINOOK_RESOURCE)) { + if (in == null) { + throw new IllegalStateException( + "Bundled Chinook DB not found on classpath: " + BUNDLED_CHINOOK_RESOURCE); + } + Path parent = target.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } + return target.toAbsolutePath(); + } + + private static String envOrDefault(String name, String defaultValue) { + String v = System.getenv(name); + return (v != null && !v.isBlank()) ? v : defaultValue; + } +} diff --git a/agentscope-examples/harness-examples/harness-example-remote/src/main/java/io/agentscope/examples/harness/remote/HarnessRemoteApplication.java b/agentscope-examples/harness-examples/harness-example-remote/src/main/java/io/agentscope/examples/harness/remote/HarnessRemoteApplication.java new file mode 100644 index 000000000..9ab7754fe --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-remote/src/main/java/io/agentscope/examples/harness/remote/HarnessRemoteApplication.java @@ -0,0 +1,36 @@ +/* + * 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.harness.remote; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Spring Boot entry for the remote-store Data Agent example. + * + *

    Set {@code DASHSCOPE_API_KEY}. Default port {@code 8788} (see {@code application.properties}). + * + *

    This module demonstrates {@link io.agentscope.harness.agent.filesystem.RemoteFilesystemSpec} + * ({@code docs/zh/harness/filesystem.md} 模式一). Sandbox (模式二) + * is {@code harness-example-sandbox}. + */ +@SpringBootApplication +public class HarnessRemoteApplication { + + public static void main(String[] args) { + SpringApplication.run(HarnessRemoteApplication.class, args); + } +} diff --git a/agentscope-examples/harness-examples/harness-example-remote/src/main/java/io/agentscope/examples/harness/remote/WorkspaceClasspathMaterializer.java b/agentscope-examples/harness-examples/harness-example-remote/src/main/java/io/agentscope/examples/harness/remote/WorkspaceClasspathMaterializer.java new file mode 100644 index 000000000..04710d7fa --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-remote/src/main/java/io/agentscope/examples/harness/remote/WorkspaceClasspathMaterializer.java @@ -0,0 +1,68 @@ +/* + * 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.harness.remote; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +/** + * Copies {@code classpath:workspace/...} into a temp directory used as the local side of the + * composite workspace for {@link io.agentscope.harness.agent.filesystem.RemoteFilesystemSpec}. + */ +public final class WorkspaceClasspathMaterializer { + + private static final String[] CLASSPATH_FILES = { + "classpath:workspace/AGENTS.md", + "classpath:workspace/skills/query-writing/SKILL.md", + "classpath:workspace/knowledge/KNOWLEDGE.md", + }; + + private WorkspaceClasspathMaterializer() {} + + /** + * Materializes bundled workspace resources to disk. + * + * @return absolute path to the host workspace directory + */ + public static Path materialize() { + try { + Path dir = Files.createTempDirectory("remote-data-agent-host-workspace-"); + PathMatchingResourcePatternResolver resolver = + new PathMatchingResourcePatternResolver(); + for (String location : CLASSPATH_FILES) { + Resource resource = resolver.getResource(location); + if (!resource.exists()) { + throw new IllegalStateException("Missing required resource: " + location); + } + String pathWithinWorkspace = location.substring("classpath:workspace/".length()); + Path target = dir.resolve(pathWithinWorkspace); + Files.createDirectories(target.getParent()); + try (InputStream in = resource.getInputStream()) { + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } + } + return dir.toAbsolutePath().normalize(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to materialize workspace from classpath", e); + } + } +} diff --git a/agentscope-examples/harness-examples/harness-example-remote/src/main/java/io/agentscope/examples/harness/remote/data/SqliteTool.java b/agentscope-examples/harness-examples/harness-example-remote/src/main/java/io/agentscope/examples/harness/remote/data/SqliteTool.java new file mode 100644 index 000000000..c9b5e5d3b --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-remote/src/main/java/io/agentscope/examples/harness/remote/data/SqliteTool.java @@ -0,0 +1,225 @@ +/* + * 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.harness.remote.data; + +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * SQLite helpers for the remote-store Data Agent — same tool names and behaviour as {@code + * io.agentscope.harness.example.SqliteTool} in {@code agentscope-examples/harness-example}. + * + *

    Tools: {@code sql_list_tables}, {@code sql_get_schema}, {@code sql_execute_query}. + */ +public class SqliteTool { + + private static final Logger log = LoggerFactory.getLogger(SqliteTool.class); + + private static final int MAX_ROWS = 50; + + private static final int SAMPLE_ROWS = 3; + + private final String jdbcUrl; + + public SqliteTool(Path dbPath) { + this.jdbcUrl = "jdbc:sqlite:" + dbPath.toAbsolutePath(); + } + + @Tool( + name = "sql_list_tables", + description = + "Lists all tables in the SQLite database. Use this first to discover what data" + + " is available before writing queries.") + public String listTables() { + try (Connection conn = DriverManager.getConnection(jdbcUrl)) { + DatabaseMetaData meta = conn.getMetaData(); + List tables = new ArrayList<>(); + try (ResultSet rs = meta.getTables(null, null, "%", new String[] {"TABLE"})) { + while (rs.next()) { + tables.add(rs.getString("TABLE_NAME")); + } + } + if (tables.isEmpty()) { + return "No tables found in the database."; + } + return "Tables (" + tables.size() + "):\n" + String.join("\n", tables); + } catch (SQLException e) { + log.warn("sql_list_tables failed", e); + return "Error listing tables: " + e.getMessage(); + } + } + + @Tool( + name = "sql_get_schema", + description = + "Returns the schema (column names, types, keys) and sample rows for one or more" + + " tables. Pass a comma-separated list of table names to inspect" + + " multiple tables at once.") + public String getSchema( + @ToolParam( + name = "tables", + description = + "Comma-separated table names, e.g. \"Artist,Album,Track\"") + String tables) { + StringBuilder sb = new StringBuilder(); + for (String table : tables.split(",")) { + table = table.strip(); + if (table.isEmpty()) continue; + sb.append(describeTable(table)).append("\n\n"); + } + return sb.toString().strip(); + } + + @Tool( + name = "sql_execute_query", + description = + "Executes a read-only SELECT query against the SQLite database and returns the" + + " results as a formatted table. Never use DML statements" + + " (INSERT / UPDATE / DELETE / DROP).") + public String executeQuery( + @ToolParam(name = "query", description = "A valid SQLite SELECT statement") + String query) { + String trimmed = query.strip(); + if (!trimmed.toUpperCase().startsWith("SELECT")) { + return "Error: only SELECT statements are allowed. Received: " + trimmed; + } + try (Connection conn = DriverManager.getConnection(jdbcUrl); + Statement stmt = conn.createStatement()) { + stmt.setMaxRows(MAX_ROWS); + try (ResultSet rs = stmt.executeQuery(trimmed)) { + return formatResultSet(rs); + } + } catch (SQLException e) { + log.warn("sql_execute_query failed for: {}", trimmed, e); + return "Error executing query: " + e.getMessage(); + } + } + + private String describeTable(String tableName) { + StringBuilder sb = new StringBuilder(); + sb.append("## ").append(tableName).append("\n\n"); + + try (Connection conn = DriverManager.getConnection(jdbcUrl)) { + DatabaseMetaData meta = conn.getMetaData(); + + List pks = new ArrayList<>(); + try (ResultSet rs = meta.getPrimaryKeys(null, null, tableName)) { + while (rs.next()) { + pks.add(rs.getString("COLUMN_NAME")); + } + } + + List fks = new ArrayList<>(); + try (ResultSet rs = meta.getImportedKeys(null, null, tableName)) { + while (rs.next()) { + fks.add( + rs.getString("FKCOLUMN_NAME") + + " → " + + rs.getString("PKTABLE_NAME") + + "." + + rs.getString("PKCOLUMN_NAME")); + } + } + + sb.append("### Columns\n"); + try (ResultSet rs = meta.getColumns(null, null, tableName, "%")) { + while (rs.next()) { + String col = rs.getString("COLUMN_NAME"); + String type = rs.getString("TYPE_NAME"); + String nullable = "YES".equals(rs.getString("IS_NULLABLE")) ? "" : " NOT NULL"; + String pk = pks.contains(col) ? " [PK]" : ""; + sb.append("- ") + .append(col) + .append(" (") + .append(type) + .append(nullable) + .append(pk) + .append(")\n"); + } + } + + if (!fks.isEmpty()) { + sb.append("\n### Foreign Keys\n"); + fks.forEach(fk -> sb.append("- ").append(fk).append("\n")); + } + + sb.append("\n### Sample Data (").append(SAMPLE_ROWS).append(" rows)\n"); + try (Statement stmt = conn.createStatement()) { + stmt.setMaxRows(SAMPLE_ROWS); + try (ResultSet rs = + stmt.executeQuery("SELECT * FROM " + tableName + " LIMIT " + SAMPLE_ROWS)) { + sb.append(formatResultSet(rs)); + } + } + + } catch (SQLException e) { + sb.append("Error describing table '") + .append(tableName) + .append("': ") + .append(e.getMessage()); + } + return sb.toString(); + } + + private static String formatResultSet(ResultSet rs) throws SQLException { + ResultSetMetaData meta = rs.getMetaData(); + int cols = meta.getColumnCount(); + + StringJoiner header = new StringJoiner(" | "); + for (int i = 1; i <= cols; i++) { + header.add(meta.getColumnName(i)); + } + + String separator = "-".repeat(header.toString().length()); + + List rows = new ArrayList<>(); + int count = 0; + while (rs.next()) { + StringJoiner row = new StringJoiner(" | "); + for (int i = 1; i <= cols; i++) { + Object val = rs.getObject(i); + row.add(val == null ? "NULL" : val.toString()); + } + rows.add(row.toString()); + count++; + } + + if (rows.isEmpty()) { + return "(no rows returned)"; + } + + StringBuilder sb = new StringBuilder(); + sb.append(header).append("\n").append(separator).append("\n"); + rows.forEach(r -> sb.append(r).append("\n")); + if (count >= MAX_ROWS) { + sb.append("... (result truncated at ").append(MAX_ROWS).append(" rows)\n"); + } + return sb.toString(); + } +} diff --git a/agentscope-examples/harness-examples/harness-example-remote/src/main/resources/application.properties b/agentscope-examples/harness-examples/harness-example-remote/src/main/resources/application.properties new file mode 100644 index 000000000..a0192ca20 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-remote/src/main/resources/application.properties @@ -0,0 +1,2 @@ +spring.application.name=harness-example-remote +server.port=8788 diff --git a/agentscope-examples/harness-examples/harness-example-remote/src/main/resources/io/agentscope/examples/harness/remote/chinook-default.sqlite b/agentscope-examples/harness-examples/harness-example-remote/src/main/resources/io/agentscope/examples/harness/remote/chinook-default.sqlite new file mode 100644 index 000000000..f385c2651 Binary files /dev/null and b/agentscope-examples/harness-examples/harness-example-remote/src/main/resources/io/agentscope/examples/harness/remote/chinook-default.sqlite differ diff --git a/agentscope-examples/harness-examples/harness-example-remote/src/main/resources/workspace/knowledge/KNOWLEDGE.md b/agentscope-examples/harness-examples/harness-example-remote/src/main/resources/workspace/knowledge/KNOWLEDGE.md new file mode 100644 index 000000000..2f30165e6 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-remote/src/main/resources/workspace/knowledge/KNOWLEDGE.md @@ -0,0 +1,12 @@ +# Chinook Knowledge (remote-store demo) + +This Data Agent uses the Chinook SQLite sample database. + +Primary entities: +- `Artist` -> `Album` -> `Track` +- `Track` -> `InvoiceLine` -> `Invoice` -> `Customer` + +Guidance: +- Always discover schema with `sql_list_tables` and `sql_get_schema`. +- Use read-only `SELECT` statements. +- Add `LIMIT` when the user did not request full output. diff --git a/agentscope-examples/harness-examples/harness-example-remote/src/main/resources/workspace/skills/query-writing/SKILL.md b/agentscope-examples/harness-examples/harness-example-remote/src/main/resources/workspace/skills/query-writing/SKILL.md new file mode 100644 index 000000000..c8315594c --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-remote/src/main/resources/workspace/skills/query-writing/SKILL.md @@ -0,0 +1,14 @@ +--- +name: query-writing +description: Discover schema, write SELECT-only SQLite queries, execute, and explain results (aligned with harness-example). +--- + +# Query writing (remote-store) + +1. Call `sql_list_tables` if you do not yet know table names. +2. Call `sql_get_schema` for each table you join or filter on. +3. Run `sql_execute_query` with a single `SELECT` (add `LIMIT` for large scans). +4. Summarise results in plain language. + +See the full skill in `agentscope-examples/harness-example` under +`src/main/resources/workspace/skills/query-writing/SKILL.md` for multi-table JOIN patterns. diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/README.md b/agentscope-examples/harness-examples/harness-example-sandbox/README.md new file mode 100644 index 000000000..23ee842e3 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-sandbox/README.md @@ -0,0 +1,76 @@ +# harness-example-sandbox — Filesystem **模式二(沙箱)** + +本模块用最小的 **Chinook Text-to-SQL Data Agent** 业务,演示在 `HarnessAgent` 中如何启用 **`SandboxFilesystemSpec`**(与 [`docs/zh/harness/filesystem.md`](../../docs/zh/harness/filesystem.md) 中的 **模式二**、以及 `HarnessAgent.Builder#filesystem(SandboxFilesystemSpec)` 注释中的 **Mode 2** 一致)。 + +--- + +## 模式二在 Harness 里是什么 + +- **对外**:仍是 `AbstractFilesystem` + `FilesystemTool`;若后端实现 `AbstractSandboxFilesystem`,Harness 会注册 **`ShellExecuteTool`**,命令在**沙箱进程/容器**里执行,而不是宿主 `sh -c`。 +- **生命周期**:`SandboxLifecycleHook` 在每次 `call` 前后 **acquire / persist / release** 沙箱,工作区投影、状态落盘由 `SandboxStateStore` + 隔离键描述。 +- **与模式一、三的区别**(简表): + +| 模式 | 典型配置 | Shell | 本仓库示例 | +|------|----------|-------|------------| +| 模式一 | `RemoteFilesystemSpec` | 无(宿主侧) | [`harness-example-remote`](../harness-example-remote/README.md) | +| **模式二** | **`SandboxFilesystemSpec`** | **有(在沙箱内)** | **本模块** | +| 模式三 | 不写 `filesystem(...)` 或 `LocalFilesystemSpec` | 有(宿主) | [`harness-example-local`](../harness-example-local/README.md) | + +--- + +## 本示例如何实现「沙箱模式」 + +### 1. 显式使用 `SandboxFilesystemSpec` 子类 + +`DataAgentService` 中构建 `HarnessAgent` 时使用: + +```java +.filesystem(fsSpec) // fsSpec 为 InMemorySandboxFilesystemSpec,extends SandboxFilesystemSpec +``` + +`InMemorySandboxFilesystemSpec` 在本模块的 `support/` 包内:用 **`InMemorySandboxClient`** 在本地 JVM 里分配临时目录,**代替**生产里的 Docker `SandboxClient`;行为上仍是「通过 Sandbox 抽象执行 shell / 投影工作区」。 + +### 2. 共享 `SandboxStateStore` + `IsolationScope.USER` + +- **`SharedInMemorySandboxStateStore`**:内存版 `SandboxStateStore`,模拟多副本共用的 **Redis / 元数据存储**(保存沙箱句柄、工作区根路径等,便于 **resume**)。 +- **`fsSpec.isolationScope(IsolationScope.USER).sandboxStateStore(stateStore)`**:同一 `userId` 多次请求(可不同 `sessionId`)**复用同一沙箱工作区**;不同 `userId` 隔离。 +- 生产部署时:各实例配置**相同的** `SandboxStateStore` 与 **同一套** `SandboxClient`/镜像策略即可;本示例把复杂度收进内存,便于单机跑通。 + +### 3. 业务层刻意保持简单 + +- **Spring Boot**:`POST /query`,body 为 `sessionId`、`userId`、`question`。 +- **宿主工作区**:`WorkspaceClasspathMaterializer` 把 classpath 里的 `AGENTS.md`、`skills/`、`knowledge/` 落到临时目录,再经沙箱 **workspace projection** 进入会话工作区(与生产「仓库里带 skills」一致)。 +- **Chinook**:`SqliteTool` 直连宿主 materialize 出的 `chinook.db`(JDBC 在 JVM 内执行,与「是否在沙箱里跑 SQL」正交;重点是 **文件与 shell 走沙箱路径**)。 + +--- + +## 运行 + +```bash +export DASHSCOPE_API_KEY=your_key +# 可选:AGENTSCOPE_MODEL(默认 qwen-max) + +cd agentscope-java +mvn -pl agentscope-examples/harness-example-sandbox -am package -DskipTests +java -jar agentscope-examples/harness-example-sandbox/target/harness-example-sandbox-*.jar +``` + +默认端口 **`8787`**(见 `application.properties`)。 + +```bash +curl -s -X POST http://localhost:8787/query \ + -H 'Content-Type: application/json' \ + -d '{"sessionId":"s1","userId":"alice","question":"How many artists are in Chinook?"}' +``` + +--- + +## 相关源码入口 + +| 文件 | 作用 | +|------|------| +| `DataAgentService.java` | `HarnessAgent.builder().filesystem(fsSpec)...` | +| `support/InMemorySandboxFilesystemSpec.java` | `SandboxFilesystemSpec` + `SandboxClient` | +| `support/SharedInMemorySandboxStateStore.java` | 分布式沙箱元数据的单机替身 | + +更完整的沙箱概念见 [`docs/zh/harness/sandbox.md`](../../docs/zh/harness/sandbox.md)。 diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/pom.xml b/agentscope-examples/harness-examples/harness-example-sandbox/pom.xml new file mode 100644 index 000000000..7bdacef8a --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-sandbox/pom.xml @@ -0,0 +1,84 @@ + + + + + 4.0.0 + + io.agentscope + harness-examples + ${revision} + ../pom.xml + + + AgentScope Java - Harness Sandbox Example + io.agentscope.examples + harness-example-sandbox + jar + Spring Boot app: in-memory sandbox Data Agent (text-to-SQL pattern from harness-example), USER isolation, shared store, projected workspace + + + + + org.springframework.boot + spring-boot-dependencies + ${spring.boot.version} + pom + import + + + + + + + io.agentscope + agentscope-harness + + + + org.xerial + sqlite-jdbc + 3.47.2.0 + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + org.springframework.boot + spring-boot-starter-web + + + + + + + org.springframework.boot + spring-boot-maven-plugin + ${spring.boot.version} + + + + repackage + + + + + + + diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/DataAgentController.java b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/DataAgentController.java new file mode 100644 index 000000000..68b834ac0 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/DataAgentController.java @@ -0,0 +1,93 @@ +/* + * 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.harness.sandbox; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +/** + * HTTP entry point for the sandbox Data Agent. + * + *

    + * GET  /        — usage hint
    + * POST /query   — ask the data agent a question
    + * 
    + * + *

    Example: + * + *

    {@code
    + * curl -X POST http://localhost:8787/query \
    + *   -H 'Content-Type: application/json' \
    + *   -d '{"sessionId":"s1","userId":"alice","question":"How many artists are in the database?"}'
    + * }
    + */ +@RestController +public class DataAgentController { + + private final DataAgentService agentService; + + public DataAgentController(DataAgentService agentService) { + this.agentService = agentService; + } + + @GetMapping("/") + public String index() { + return "Sandbox Data Agent — POST /query with" + + " {\"sessionId\":\"...\",\"userId\":\"...\",\"question\":\"...\"}"; + } + + /** + * Processes a natural-language question about the Chinook database. + * + *

    The agent sandbox and memory are scoped by {@code userId}: the same user always resumes + * the same sandbox (and sees the same {@code MEMORY.md}) regardless of {@code sessionId}. The + * conversation context (messages) is scoped by {@code sessionId}. + */ + @PostMapping("/query") + public ResponseEntity query(@RequestBody QueryRequest request) { + if (blank(request.sessionId())) { + return ResponseEntity.badRequest().body(QueryResponse.error("sessionId is required")); + } + if (blank(request.userId())) { + return ResponseEntity.badRequest().body(QueryResponse.error("userId is required")); + } + if (blank(request.question())) { + return ResponseEntity.badRequest().body(QueryResponse.error("question is required")); + } + String answer = + agentService.query(request.sessionId(), request.userId(), request.question()); + return ResponseEntity.ok(QueryResponse.ok(answer)); + } + + private static boolean blank(String s) { + return s == null || s.isBlank(); + } + + record QueryRequest(String sessionId, String userId, String question) {} + + record QueryResponse(String answer, String error) { + static QueryResponse ok(String answer) { + return new QueryResponse(answer, null); + } + + static QueryResponse error(String error) { + return new QueryResponse(null, error); + } + } +} diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/DataAgentService.java b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/DataAgentService.java new file mode 100644 index 000000000..2f8df5cb2 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/DataAgentService.java @@ -0,0 +1,166 @@ +/* + * 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.harness.sandbox; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.examples.harness.sandbox.data.SqliteTool; +import io.agentscope.examples.harness.sandbox.support.InMemorySandboxClient; +import io.agentscope.examples.harness.sandbox.support.InMemorySandboxFilesystemSpec; +import io.agentscope.examples.harness.sandbox.support.SharedInMemorySandboxStateStore; +import io.agentscope.harness.agent.HarnessAgent; +import io.agentscope.harness.agent.IsolationScope; +import io.agentscope.harness.agent.sandbox.SandboxDistributedOptions; +import jakarta.annotation.PostConstruct; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +/** + * Data Agent service: Chinook text-to-SQL with sandbox isolation. + * + *

    Infrastructure shared across all requests (initialized once on startup): + * + *

      + *
    • {@link InMemorySandboxClient} — local-process sandbox (stands in for Docker in + * production) + *
    • {@link SharedInMemorySandboxStateStore} — shared state store (stands in for Redis) + *
    • {@link InMemorySandboxFilesystemSpec} with {@link IsolationScope#USER} — each unique + * {@code userId} gets its own sandbox; the sandbox persists across sessions for that user + *
    + * + *

    A fresh {@link HarnessAgent} is built per request so concurrent calls with different + * {@code userId} values do not interfere. The heavy shared state (sandbox instance, workspace + * files, memory) lives in the stores above, not in the agent object itself. + * + *

    Required environment variables: + * + *

      + *
    • {@code DASHSCOPE_API_KEY} — DashScope API key + *
    • {@code AGENTSCOPE_MODEL} — model name (default: {@code qwen-max}) + *
    + */ +@Service +public class DataAgentService { + + private static final Logger log = LoggerFactory.getLogger(DataAgentService.class); + + private static final String AGENT_NAME = "data-agent"; + private static final String BUNDLED_CHINOOK_RESOURCE = "chinook-default.sqlite"; + private static final String SYS_PROMPT = + "You are a Text-to-SQL agent with access to the Chinook music store database." + + " Use the sql_* tools to explore the schema and run read-only SELECT queries." + + " Follow the query-writing skill and answer clearly in plain language."; + + private Path hostWorkspace; + private Path dbPath; + private String llmModelId; + private InMemorySandboxFilesystemSpec fsSpec; + private SharedInMemorySandboxStateStore stateStore; + + @PostConstruct + void init() throws Exception { + hostWorkspace = WorkspaceClasspathMaterializer.materialize(); + dbPath = materialiseChinook(hostWorkspace.resolve("chinook.db")); + llmModelId = resolveLlmModelId(); + + stateStore = new SharedInMemorySandboxStateStore(); + fsSpec = new InMemorySandboxFilesystemSpec(new InMemorySandboxClient()); + fsSpec.isolationScope(IsolationScope.USER).sandboxStateStore(stateStore); + + log.info("DataAgentService ready: workspace={} db={}", hostWorkspace, dbPath); + } + + /** + * Processes a user question with the data agent. + * + *
      + *
    • {@code sessionId} — scopes the conversation history (same session = same chat context) + *
    • {@code userId} — scopes the sandbox workspace and memory (same user = shared sandbox + * and MEMORY.md across sessions) + *
    + * + * @param sessionId session identifier + * @param userId user identifier + * @param question natural-language question about the Chinook database + * @return agent answer + */ + public String query(String sessionId, String userId, String question) { + Toolkit toolkit = new Toolkit(); + toolkit.registerTool(new SqliteTool(dbPath)); + + HarnessAgent agent = + HarnessAgent.builder() + .name(AGENT_NAME) + .model(llmModelId) + .workspace(hostWorkspace) + .filesystem(fsSpec) + .sysPrompt(SYS_PROMPT) + .toolkit(toolkit) + .sandboxDistributed( + SandboxDistributedOptions.builder() + .requireDistributed(false) + .build()) + .enableAgentTracingLog(true) + .build(); + + RuntimeContext ctx = RuntimeContext.builder().sessionId(sessionId).userId(userId).build(); + Msg userMsg = Msg.builder().role(MsgRole.USER).textContent(question).build(); + Msg reply = agent.call(userMsg, ctx).block(); + return reply != null ? reply.getTextContent() : "(no response)"; + } + + private String resolveLlmModelId() { + String apiKey = System.getenv("DASHSCOPE_API_KEY"); + if (apiKey == null || apiKey.isBlank()) { + throw new IllegalStateException("Environment variable DASHSCOPE_API_KEY is not set."); + } + String modelName = envOrDefault("AGENTSCOPE_MODEL", "qwen-max"); + String id = "dashscope:" + modelName; + log.info("Using model: {}", id); + return id; + } + + private static Path materialiseChinook(Path target) throws Exception { + if (Files.exists(target)) { + return target.toAbsolutePath(); + } + try (InputStream in = + DataAgentService.class.getResourceAsStream(BUNDLED_CHINOOK_RESOURCE)) { + if (in == null) { + throw new IllegalStateException( + "Bundled Chinook DB not found on classpath: " + BUNDLED_CHINOOK_RESOURCE); + } + Path parent = target.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } + return target.toAbsolutePath(); + } + + private static String envOrDefault(String name, String defaultValue) { + String v = System.getenv(name); + return (v != null && !v.isBlank()) ? v : defaultValue; + } +} diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/HarnessSandboxApplication.java b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/HarnessSandboxApplication.java new file mode 100644 index 000000000..953e784d8 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/HarnessSandboxApplication.java @@ -0,0 +1,41 @@ +/* + * 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.harness.sandbox; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +/** + * Spring Boot entry for the sandbox Data Agent (text-to-SQL) demo. + * + *

    Set {@code DASHSCOPE_API_KEY} before starting. Optionally override the model with + * {@code AGENTSCOPE_MODEL} (default: {@code qwen-max}). + * + *

    Once started, send questions via: + * + *

    + * curl -X POST http://localhost:8787/query \
    + *   -H 'Content-Type: application/json' \
    + *   -d '{"sessionId":"s1","userId":"alice","question":"How many artists are there?"}'
    + * 
    + */ +@SpringBootApplication +public class HarnessSandboxApplication { + + public static void main(String[] args) { + SpringApplication.run(HarnessSandboxApplication.class, args); + } +} diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/WorkspaceClasspathMaterializer.java b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/WorkspaceClasspathMaterializer.java new file mode 100644 index 000000000..7c425ed1d --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/WorkspaceClasspathMaterializer.java @@ -0,0 +1,70 @@ +/* + * 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.harness.sandbox; + +import java.io.IOException; +import java.io.InputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; + +/** + * Copies known {@code classpath:workspace/...} entries into a temp directory so {@link + * io.agentscope.harness.agent.HarnessAgent} can use it as the host workspace root for sandbox + * projection (e.g. {@code skills/query-writing/} for the Data Agent demo). + */ +public final class WorkspaceClasspathMaterializer { + + private static final String[] CLASSPATH_FILES = { + "classpath:workspace/AGENTS.md", + "classpath:workspace/skills/query-writing/SKILL.md", + "classpath:workspace/knowledge/KNOWLEDGE.md", + }; + + private WorkspaceClasspathMaterializer() {} + + /** + * Materializes bundled workspace resources to disk. + * + * @return absolute path to the host workspace directory (contains {@code AGENTS.md}, + * {@code skills/}, …) + */ + public static Path materialize() { + try { + Path dir = Files.createTempDirectory("data-agent-host-workspace-"); + PathMatchingResourcePatternResolver resolver = + new PathMatchingResourcePatternResolver(); + for (String location : CLASSPATH_FILES) { + Resource resource = resolver.getResource(location); + if (!resource.exists()) { + throw new IllegalStateException("Missing required resource: " + location); + } + String pathWithinWorkspace = location.substring("classpath:workspace/".length()); + Path target = dir.resolve(pathWithinWorkspace); + Files.createDirectories(target.getParent()); + try (InputStream in = resource.getInputStream()) { + Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING); + } + } + return dir.toAbsolutePath().normalize(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to materialize workspace from classpath", e); + } + } +} diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/data/SqliteTool.java b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/data/SqliteTool.java new file mode 100644 index 000000000..9649b157c --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/data/SqliteTool.java @@ -0,0 +1,225 @@ +/* + * 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.harness.sandbox.data; + +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * SQLite helpers for the sandbox Data Agent — same tool names and behaviour as {@code + * io.agentscope.harness.example.SqliteTool} in {@code agentscope-examples/harness-example}. + * + *

    Tools: {@code sql_list_tables}, {@code sql_get_schema}, {@code sql_execute_query}. + */ +public class SqliteTool { + + private static final Logger log = LoggerFactory.getLogger(SqliteTool.class); + + private static final int MAX_ROWS = 50; + + private static final int SAMPLE_ROWS = 3; + + private final String jdbcUrl; + + public SqliteTool(Path dbPath) { + this.jdbcUrl = "jdbc:sqlite:" + dbPath.toAbsolutePath(); + } + + @Tool( + name = "sql_list_tables", + description = + "Lists all tables in the SQLite database. Use this first to discover what data" + + " is available before writing queries.") + public String listTables() { + try (Connection conn = DriverManager.getConnection(jdbcUrl)) { + DatabaseMetaData meta = conn.getMetaData(); + List tables = new ArrayList<>(); + try (ResultSet rs = meta.getTables(null, null, "%", new String[] {"TABLE"})) { + while (rs.next()) { + tables.add(rs.getString("TABLE_NAME")); + } + } + if (tables.isEmpty()) { + return "No tables found in the database."; + } + return "Tables (" + tables.size() + "):\n" + String.join("\n", tables); + } catch (SQLException e) { + log.warn("sql_list_tables failed", e); + return "Error listing tables: " + e.getMessage(); + } + } + + @Tool( + name = "sql_get_schema", + description = + "Returns the schema (column names, types, keys) and sample rows for one or more" + + " tables. Pass a comma-separated list of table names to inspect" + + " multiple tables at once.") + public String getSchema( + @ToolParam( + name = "tables", + description = + "Comma-separated table names, e.g. \"Artist,Album,Track\"") + String tables) { + StringBuilder sb = new StringBuilder(); + for (String table : tables.split(",")) { + table = table.strip(); + if (table.isEmpty()) continue; + sb.append(describeTable(table)).append("\n\n"); + } + return sb.toString().strip(); + } + + @Tool( + name = "sql_execute_query", + description = + "Executes a read-only SELECT query against the SQLite database and returns the" + + " results as a formatted table. Never use DML statements" + + " (INSERT / UPDATE / DELETE / DROP).") + public String executeQuery( + @ToolParam(name = "query", description = "A valid SQLite SELECT statement") + String query) { + String trimmed = query.strip(); + if (!trimmed.toUpperCase().startsWith("SELECT")) { + return "Error: only SELECT statements are allowed. Received: " + trimmed; + } + try (Connection conn = DriverManager.getConnection(jdbcUrl); + Statement stmt = conn.createStatement()) { + stmt.setMaxRows(MAX_ROWS); + try (ResultSet rs = stmt.executeQuery(trimmed)) { + return formatResultSet(rs); + } + } catch (SQLException e) { + log.warn("sql_execute_query failed for: {}", trimmed, e); + return "Error executing query: " + e.getMessage(); + } + } + + private String describeTable(String tableName) { + StringBuilder sb = new StringBuilder(); + sb.append("## ").append(tableName).append("\n\n"); + + try (Connection conn = DriverManager.getConnection(jdbcUrl)) { + DatabaseMetaData meta = conn.getMetaData(); + + List pks = new ArrayList<>(); + try (ResultSet rs = meta.getPrimaryKeys(null, null, tableName)) { + while (rs.next()) { + pks.add(rs.getString("COLUMN_NAME")); + } + } + + List fks = new ArrayList<>(); + try (ResultSet rs = meta.getImportedKeys(null, null, tableName)) { + while (rs.next()) { + fks.add( + rs.getString("FKCOLUMN_NAME") + + " → " + + rs.getString("PKTABLE_NAME") + + "." + + rs.getString("PKCOLUMN_NAME")); + } + } + + sb.append("### Columns\n"); + try (ResultSet rs = meta.getColumns(null, null, tableName, "%")) { + while (rs.next()) { + String col = rs.getString("COLUMN_NAME"); + String type = rs.getString("TYPE_NAME"); + String nullable = "YES".equals(rs.getString("IS_NULLABLE")) ? "" : " NOT NULL"; + String pk = pks.contains(col) ? " [PK]" : ""; + sb.append("- ") + .append(col) + .append(" (") + .append(type) + .append(nullable) + .append(pk) + .append(")\n"); + } + } + + if (!fks.isEmpty()) { + sb.append("\n### Foreign Keys\n"); + fks.forEach(fk -> sb.append("- ").append(fk).append("\n")); + } + + sb.append("\n### Sample Data (").append(SAMPLE_ROWS).append(" rows)\n"); + try (Statement stmt = conn.createStatement()) { + stmt.setMaxRows(SAMPLE_ROWS); + try (ResultSet rs = + stmt.executeQuery("SELECT * FROM " + tableName + " LIMIT " + SAMPLE_ROWS)) { + sb.append(formatResultSet(rs)); + } + } + + } catch (SQLException e) { + sb.append("Error describing table '") + .append(tableName) + .append("': ") + .append(e.getMessage()); + } + return sb.toString(); + } + + private static String formatResultSet(ResultSet rs) throws SQLException { + ResultSetMetaData meta = rs.getMetaData(); + int cols = meta.getColumnCount(); + + StringJoiner header = new StringJoiner(" | "); + for (int i = 1; i <= cols; i++) { + header.add(meta.getColumnName(i)); + } + + String separator = "-".repeat(header.toString().length()); + + List rows = new ArrayList<>(); + int count = 0; + while (rs.next()) { + StringJoiner row = new StringJoiner(" | "); + for (int i = 1; i <= cols; i++) { + Object val = rs.getObject(i); + row.add(val == null ? "NULL" : val.toString()); + } + rows.add(row.toString()); + count++; + } + + if (rows.isEmpty()) { + return "(no rows returned)"; + } + + StringBuilder sb = new StringBuilder(); + sb.append(header).append("\n").append(separator).append("\n"); + rows.forEach(r -> sb.append(r).append("\n")); + if (count >= MAX_ROWS) { + sb.append("... (result truncated at ").append(MAX_ROWS).append(" rows)\n"); + } + return sb.toString(); + } +} diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/support/InMemorySandbox.java b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/support/InMemorySandbox.java new file mode 100644 index 000000000..a51409c0b --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/support/InMemorySandbox.java @@ -0,0 +1,170 @@ +/* + * 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.harness.sandbox.support; + +import io.agentscope.harness.agent.sandbox.ExecResult; +import io.agentscope.harness.agent.sandbox.Sandbox; +import io.agentscope.harness.agent.sandbox.SandboxState; +import io.agentscope.harness.agent.sandbox.WorkspaceProjectionApplier; +import io.agentscope.harness.agent.sandbox.WorkspaceSpec; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Objects; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; + +/** + * In-process {@link Sandbox} that uses a local temp directory as the workspace (for examples). + * + *

    Applies {@link WorkspaceProjectionApplier} payloads on {@link #start} and extracts tar + * archives in {@link #hydrateWorkspace} so host-projected skills match production behaviour. + */ +public class InMemorySandbox implements Sandbox { + + private final InMemorySandboxState state; + private final Path workspaceDir; + private final AtomicBoolean running = new AtomicBoolean(false); + private final int defaultTimeoutSeconds; + + public InMemorySandbox(InMemorySandboxState state, int defaultTimeoutSeconds) { + this.state = state; + this.workspaceDir = Path.of(state.getWorkspaceRoot()); + this.defaultTimeoutSeconds = defaultTimeoutSeconds; + } + + @Override + public void start() throws Exception { + if (!Files.exists(workspaceDir)) { + Files.createDirectories(workspaceDir); + } + applyWorkspaceProjectionIfChanged(state.getWorkspaceSpec()); + state.setWorkspaceRootReady(true); + running.set(true); + } + + private void applyWorkspaceProjectionIfChanged(WorkspaceSpec spec) throws Exception { + WorkspaceProjectionApplier.ProjectionPayload payload = + WorkspaceProjectionApplier.build(spec); + if (payload == null) { + return; + } + if (Objects.equals(payload.hash(), state.getWorkspaceProjectionHash())) { + return; + } + if (payload.fileCount() > 0) { + try (InputStream archive = new ByteArrayInputStream(payload.tarBytes())) { + hydrateWorkspace(archive); + } + } + state.setWorkspaceProjectionHash(payload.hash()); + } + + @Override + public void stop() throws Exception { + state.setWorkspaceRootReady(true); + running.set(false); + } + + @Override + public void shutdown() throws Exception { + // Leave workspace dir in place for resume in tests + } + + @Override + public void close() throws Exception { + try { + stop(); + } catch (Exception e) { + // best-effort + } + shutdown(); + } + + @Override + public boolean isRunning() { + return running.get(); + } + + @Override + public SandboxState getState() { + return state; + } + + @Override + public ExecResult exec(String command, Integer timeoutSeconds) throws Exception { + int timeout = timeoutSeconds != null ? timeoutSeconds : defaultTimeoutSeconds; + ProcessBuilder pb = new ProcessBuilder("sh", "-c", command); + pb.directory(workspaceDir.toFile()); + pb.redirectErrorStream(false); + Process process = pb.start(); + + boolean finished = process.waitFor(timeout, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + return new ExecResult(124, "", "Command timed out after " + timeout + "s", false); + } + + String stdout = new String(process.getInputStream().readAllBytes()); + String stderr = new String(process.getErrorStream().readAllBytes()); + return new ExecResult(process.exitValue(), stdout, stderr, false); + } + + @Override + public InputStream persistWorkspace() throws Exception { + return new ByteArrayInputStream(new byte[1024]); + } + + @Override + public void hydrateWorkspace(InputStream archive) throws Exception { + if (archive == null) { + return; + } + Path root = workspaceDir.normalize(); + try (TarArchiveInputStream tar = new TarArchiveInputStream(archive)) { + TarArchiveEntry entry; + while ((entry = tar.getNextEntry()) != null) { + if (entry.isDirectory()) { + continue; + } + String name = entry.getName(); + if (name.startsWith("/")) { + name = name.substring(1); + } + if (name.isBlank()) { + continue; + } + Path dest = root.resolve(name).normalize(); + if (!dest.startsWith(root)) { + throw new IOException("Tar entry escapes workspace: " + name); + } + Files.createDirectories(dest.getParent()); + try (OutputStream out = Files.newOutputStream(dest)) { + tar.transferTo(out); + } + } + } + } + + public Path getWorkspaceDir() { + return workspaceDir; + } +} diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/support/InMemorySandboxClient.java b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/support/InMemorySandboxClient.java new file mode 100644 index 000000000..88b9c6c16 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/support/InMemorySandboxClient.java @@ -0,0 +1,122 @@ +/* + * 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.harness.sandbox.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.harness.agent.sandbox.Sandbox; +import io.agentscope.harness.agent.sandbox.SandboxClient; +import io.agentscope.harness.agent.sandbox.SandboxClientOptions; +import io.agentscope.harness.agent.sandbox.SandboxState; +import io.agentscope.harness.agent.sandbox.WorkspaceSpec; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +/** In-process {@link SandboxClient} that allocates local temp directories as sandboxes. */ +public class InMemorySandboxClient implements SandboxClient { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int DEFAULT_TIMEOUT_SECONDS = 30; + + private final AtomicInteger createCount = new AtomicInteger(0); + private final AtomicInteger resumeCount = new AtomicInteger(0); + private final Path baseDir; + + public InMemorySandboxClient() { + try { + this.baseDir = Files.createTempDirectory("agentscope-inmemory-sandbox-"); + } catch (IOException e) { + throw new UncheckedIOException( + "Failed to create base temp dir for InMemorySandboxClient", e); + } + } + + @Override + public Sandbox create( + WorkspaceSpec workspaceSpec, + SandboxSnapshotSpec snapshotSpec, + SandboxClientOptions options) { + createCount.incrementAndGet(); + String sessionId = UUID.randomUUID().toString(); + Path workspaceDir = baseDir.resolve(sessionId); + try { + Files.createDirectories(workspaceDir); + } catch (IOException e) { + throw new UncheckedIOException("Failed to create workspace dir", e); + } + + InMemorySandboxState state = new InMemorySandboxState(sessionId, workspaceDir.toString()); + WorkspaceSpec spec = workspaceSpec != null ? workspaceSpec.copy() : new WorkspaceSpec(); + spec.setRoot(workspaceDir.toString()); + state.setWorkspaceSpec(spec); + + return new InMemorySandbox(state, DEFAULT_TIMEOUT_SECONDS); + } + + @Override + public Sandbox resume(SandboxState sessionState) { + resumeCount.incrementAndGet(); + InMemorySandboxState state = (InMemorySandboxState) sessionState; + return new InMemorySandbox(state, DEFAULT_TIMEOUT_SECONDS); + } + + @Override + public void delete(Sandbox session) { + // no-op + } + + @Override + public String serializeState(SandboxState state) { + try { + InMemorySandboxState s = (InMemorySandboxState) state; + return MAPPER.writeValueAsString(new StateDto(s.getSessionId(), s.getWorkspaceRoot())); + } catch (IOException e) { + throw new UncheckedIOException("Failed to serialize sandbox state", e); + } + } + + @Override + public SandboxState deserializeState(String json) { + try { + StateDto dto = MAPPER.readValue(json, StateDto.class); + InMemorySandboxState state = + new InMemorySandboxState(dto.sessionId(), dto.workspaceRoot()); + state.setWorkspaceRootReady(true); + return state; + } catch (IOException e) { + throw new UncheckedIOException("Failed to deserialize sandbox state", e); + } + } + + public int getCreateCount() { + return createCount.get(); + } + + public int getResumeCount() { + return resumeCount.get(); + } + + public void resetCounts() { + createCount.set(0); + resumeCount.set(0); + } + + record StateDto(String sessionId, String workspaceRoot) {} +} diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/support/InMemorySandboxFilesystemSpec.java b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/support/InMemorySandboxFilesystemSpec.java new file mode 100644 index 000000000..b34b7e5fb --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/support/InMemorySandboxFilesystemSpec.java @@ -0,0 +1,81 @@ +/* + * 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.harness.sandbox.support; + +import io.agentscope.harness.agent.sandbox.SandboxClient; +import io.agentscope.harness.agent.sandbox.SandboxClientOptions; +import io.agentscope.harness.agent.sandbox.WorkspaceSpec; +import io.agentscope.harness.agent.sandbox.filesystem.SandboxFilesystemSpec; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; + +/** + * {@link SandboxFilesystemSpec} backed by an {@link InMemorySandboxClient}. + * + *

    Uses a local temp directory as the sandbox workspace. No Docker or external services are + * required. The {@link InMemorySandboxClient} exposes create/resume counters to illustrate + * {@link io.agentscope.harness.agent.IsolationScope} behaviour. + */ +public class InMemorySandboxFilesystemSpec extends SandboxFilesystemSpec { + + private final InMemorySandboxClient client; + + /** + * Creates a spec backed by the given in-memory sandbox client. + * + * @param client the in-memory client to use for session create/resume + */ + public InMemorySandboxFilesystemSpec(InMemorySandboxClient client) { + this.client = client; + } + + /** + * Creates a spec with a new default {@link InMemorySandboxClient}. + */ + public InMemorySandboxFilesystemSpec() { + this(new InMemorySandboxClient()); + } + + /** + * Returns the underlying {@link InMemorySandboxClient}. + * + * @return the client + */ + public InMemorySandboxClient getClient() { + return client; + } + + @Override + protected SandboxClient createClient() { + return client; + } + + @Override + protected SandboxClientOptions clientOptions() { + return null; + } + + @Override + protected SandboxSnapshotSpec snapshotSpec() { + return null; + } + + @Override + protected WorkspaceSpec workspaceSpec() { + WorkspaceSpec s = new WorkspaceSpec(); + s.setRoot("/workspace"); + return s; + } +} diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/support/InMemorySandboxState.java b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/support/InMemorySandboxState.java new file mode 100644 index 000000000..d2be8fc79 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/support/InMemorySandboxState.java @@ -0,0 +1,41 @@ +/* + * 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.harness.sandbox.support; + +import io.agentscope.harness.agent.sandbox.SandboxState; + +/** + * In-memory {@link SandboxState} for {@link InMemorySandboxClient}. + */ +public class InMemorySandboxState extends SandboxState { + + private String workspaceRoot; + + public InMemorySandboxState() {} + + public InMemorySandboxState(String sessionId, String workspaceRoot) { + setSessionId(sessionId); + this.workspaceRoot = workspaceRoot; + } + + public String getWorkspaceRoot() { + return workspaceRoot; + } + + public void setWorkspaceRoot(String workspaceRoot) { + this.workspaceRoot = workspaceRoot; + } +} diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/support/SharedInMemorySandboxStateStore.java b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/support/SharedInMemorySandboxStateStore.java new file mode 100644 index 000000000..59d06f7c8 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/java/io/agentscope/examples/harness/sandbox/support/SharedInMemorySandboxStateStore.java @@ -0,0 +1,52 @@ +/* + * 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.harness.sandbox.support; + +import io.agentscope.harness.agent.sandbox.SandboxIsolationKey; +import io.agentscope.harness.agent.sandbox.SandboxStateStore; +import java.io.IOException; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * In-memory {@link SandboxStateStore} for examples: simulates a shared Redis-style slot map so + * multiple {@link io.agentscope.harness.agent.HarnessAgent} instances can resume the same user's + * sandbox metadata. + */ +public final class SharedInMemorySandboxStateStore implements SandboxStateStore { + + private final ConcurrentHashMap store = new ConcurrentHashMap<>(); + + private static String mapKey(SandboxIsolationKey key) { + return key.getScope().name() + ":" + key.getValue(); + } + + @Override + public Optional load(SandboxIsolationKey key) throws IOException { + String v = store.get(mapKey(key)); + return Optional.ofNullable(v); + } + + @Override + public void save(SandboxIsolationKey key, String json) throws IOException { + store.put(mapKey(key), json); + } + + @Override + public void delete(SandboxIsolationKey key) throws IOException { + store.remove(mapKey(key)); + } +} diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/src/main/resources/application.properties b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/resources/application.properties new file mode 100644 index 000000000..37ad2486c --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/resources/application.properties @@ -0,0 +1,2 @@ +spring.application.name=harness-example-sandbox +server.port=8787 diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/src/main/resources/io/agentscope/examples/harness/sandbox/chinook-default.sqlite b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/resources/io/agentscope/examples/harness/sandbox/chinook-default.sqlite new file mode 100644 index 000000000..f385c2651 Binary files /dev/null and b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/resources/io/agentscope/examples/harness/sandbox/chinook-default.sqlite differ diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/src/main/resources/workspace/knowledge/KNOWLEDGE.md b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/resources/workspace/knowledge/KNOWLEDGE.md new file mode 100644 index 000000000..1203157cd --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/resources/workspace/knowledge/KNOWLEDGE.md @@ -0,0 +1,12 @@ +# Chinook Knowledge (sandbox demo) + +This Data Agent uses the Chinook SQLite sample database. + +Primary entities: +- `Artist` -> `Album` -> `Track` +- `Track` -> `InvoiceLine` -> `Invoice` -> `Customer` + +Guidance: +- Always discover schema with `sql_list_tables` and `sql_get_schema`. +- Use read-only `SELECT` statements. +- Add `LIMIT` when the user did not request full output. diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/src/main/resources/workspace/skills/query-writing/SKILL.md b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/resources/workspace/skills/query-writing/SKILL.md new file mode 100644 index 000000000..055899406 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-example-sandbox/src/main/resources/workspace/skills/query-writing/SKILL.md @@ -0,0 +1,14 @@ +--- +name: query-writing +description: Discover schema, write SELECT-only SQLite queries, execute, and explain results (aligned with harness-example). +--- + +# Query writing (sandbox) + +1. Call `sql_list_tables` if you do not yet know table names. +2. Call `sql_get_schema` for each table you join or filter on. +3. Run `sql_execute_query` with a single `SELECT` (add `LIMIT` for large scans). +4. Summarise results in plain language. + +See the full skill in `agentscope-examples/harness-example` under +`src/main/resources/workspace/skills/query-writing/SKILL.md` for multi-table JOIN patterns. diff --git a/agentscope-examples/harness-examples/harness-quickstart/README.md b/agentscope-examples/harness-examples/harness-quickstart/README.md new file mode 100644 index 000000000..e3023651a --- /dev/null +++ b/agentscope-examples/harness-examples/harness-quickstart/README.md @@ -0,0 +1,201 @@ +# AgentScope Harness Example — Text-to-SQL Agent + +A practical example showing how to build a domain-specific agent with the **AgentScope Harness** +framework. The scenario: translate natural-language questions into SQL queries against the +[Chinook](https://github.com/lerocha/chinook-database) SQLite sample database. + +## What This Example Covers + +| Feature | How it is demonstrated | +|---------|------------------------| +| **Workspace** | Pre-populated from bundled resources by `WorkspaceInitializer` | +| **AGENTS.md** | Agent persona, principles, and workflow loaded automatically | +| **skills/** | `query-writing` and `schema-exploration` loaded on demand | +| **knowledge/** | Chinook schema reference injected as domain knowledge | +| **subagents/** | `schema-analyst` and `query-optimizer` available for delegation | +| **Custom tool** | `SqliteTool` registered via `Toolkit` alongside harness defaults | +| **Memory tools** | `memory_search` / `memory_get` enabled for cross-session recall | +| **RuntimeContext** | Session ID bound per invocation for stateful multi-turn use | + +## Project Layout + +``` +harness-examples/harness-quickstart/ +├── pom.xml +├── .env.example # Environment variable template +├── README.md # This file +└── src/main/ + ├── java/io/agentscope/harness/example/ + │ ├── TextToSqlExample.java # Main entry point (CLI) + │ ├── SqliteTool.java # Custom @Tool: list_tables, get_schema, execute_query + │ └── WorkspaceInitializer.java # Copies bundled workspace to disk on first run + └── resources/ + ├── log4j2.xml # Log4j2 console layout and logger levels + ├── log4j2.component.properties # Log4j2 component flags (e.g. disable JMX) + ├── agentscope.json.example # Agent configuration template + ├── io/agentscope/harness/example/ + │ └── chinook-default.sqlite # Bundled Chinook DB (copied to AGENTSCOPE_DB_PATH if missing) + └── workspace/ # Bundled workspace template (extracted at runtime) + ├── AGENTS.md # Agent identity and core rules + ├── MEMORY.md # Persistent notes (pre-seeded) + ├── knowledge/ + │ └── KNOWLEDGE.md # Full Chinook schema reference + ├── skills/ + │ ├── query-writing/ + │ │ └── SKILL.md # SQL query writing workflow + │ └── schema-exploration/ + │ └── SKILL.md # Database structure discovery workflow + └── subagents/ + ├── schema-analyst.md # Schema documentation specialist + └── query-optimizer.md # Query optimisation specialist +``` + +## Quick Start + +### 1. Build + +```bash +cd agentscope-java +mvn -pl agentscope-examples/harness-examples/harness-quickstart package -am -DskipTests +``` + +### 2. Chinook database (optional) + +The example ships a bundled Chinook SQLite file in the JAR. On first run, if `chinook.db` (or +`AGENTSCOPE_DB_PATH`) is missing, it is copied from the classpath automatically. Use your own file +only when you want a different path or a refreshed copy from upstream: + +```bash +curl -L -o chinook.db \ + https://github.com/lerocha/chinook-database/raw/master/ChinookDatabase/DataSources/Chinook_Sqlite.sqlite +``` + +### 3. Set your API key + +```bash +export DASHSCOPE_API_KEY=your_key_here +``` + +Or copy `.env.example` → `.env` and fill in your values, then load it: + +```bash +cp .env.example .env +# edit .env, then: +source <(grep -v '^#' .env | sed 's/^/export /') +``` + +### 4. Run + +Interactive (no arguments — type questions at the `>` prompt; `quit`, empty line, or Ctrl-D to +exit): + +```bash +java -cp target/harness-quickstart-*.jar \ + io.agentscope.harness.example.TextToSqlExample +``` + +One-shot (single question, then exit — useful for scripts): + +```bash +java -cp target/harness-quickstart-*.jar \ + io.agentscope.harness.example.TextToSqlExample \ + "What are the top 5 best-selling artists?" +``` + +Example prompts you can paste at `>`: + +- Which customers are from Brazil? +- Show me the monthly revenue trend for 2013 +- Which employee has the most customers? +- What are the top genres by number of tracks? +- How many tracks are longer than 5 minutes? + +## Logging (Log4j2) + +This module uses **Log4j2** with `log4j-slf4j2-impl` as the SLF4J binding. Configuration lives in +[`src/main/resources/log4j2.xml`](src/main/resources/log4j2.xml) (console appender, tuned levels +for Netty / Reactor / OkHttp). + +Set **`AGENTSCOPE_LOG_LEVEL`** (for example `DEBUG`) to change verbosity for `io.agentscope.*` +without editing the XML. To use a different file at runtime: + +```bash +java -Dlog4j.configurationFile=/path/to/log4j2-custom.xml ... +``` + +## How It Works + +### Workspace Initialisation + +On first run `WorkspaceInitializer.init(workspace)` extracts the bundled template files from the +JAR into `.agentscope/workspace/`. On subsequent runs existing files are preserved so the agent's +accumulated notes survive across restarts. + +``` +.agentscope/workspace/ +├── AGENTS.md ← always loaded into system prompt +├── MEMORY.md ← loaded into +├── knowledge/KNOWLEDGE.md ← loaded into +├── skills/ ← skill descriptions shown; full content loaded on demand +└── subagents/ ← subagent specs registered as callable agents +``` + +### Agent Construction + +```java +HarnessAgent agent = HarnessAgent.builder() + .name("text-to-sql") + .model(model) + .workspace(workspace) // ← harness reads all workspace files from here + .toolkit(toolkit) // ← includes our custom SqliteTool + .maxIters(20) + .build(); +``` + +### Custom Tool Registration + +```java +Toolkit toolkit = new Toolkit(); +toolkit.registerTool(new SqliteTool(dbPath)); // adds sql_list_tables, sql_get_schema, sql_execute_query +``` + +`SqliteTool` uses `@Tool` / `@ToolParam` annotations — the same mechanism used by all harness +built-in tools. Methods are discovered at runtime via reflection and exposed to the LLM as JSON +schemas. + +### Calling the Agent + +```java +RuntimeContext ctx = RuntimeContext.builder() + .sessionId("my-session-id") + .build(); + +Msg reply = agent.call(Msg.userMsg("Top 5 artists?"), ctx).block(); +``` + +The `RuntimeContext` carries the session ID used by hooks (WorkspaceContextHook, MemoryFlushHook) to +isolate per-session state and persist memory between turns in the same session. + +## Customising the Agent + +All behaviour can be tuned by editing files in `.agentscope/workspace/` — no recompilation needed: + +| File | What to change | +|------|----------------| +| `AGENTS.md` | Persona, rules, communication style | +| `MEMORY.md` | Pre-seed knowledge the agent should know from turn one | +| `knowledge/KNOWLEDGE.md` | Domain knowledge (schema details, business rules) | +| `skills/*/SKILL.md` | Step-by-step workflows for specific task types | +| `subagents/*.md` | Add / remove / reconfigure specialist subagents | + +## Dependencies + +| Dependency | Purpose | +|------------|---------| +| `agentscope-harness` | Core framework (HarnessAgent, tools, hooks, workspace) | +| `jackson-dataformat-yaml` | YAML front-matter parsing in skill/subagent files | +| `sqlite-jdbc` | SQLite JDBC driver used by `SqliteTool` | + +## License + +Apache 2.0 — see the root `LICENSE` file. diff --git a/agentscope-examples/harness-examples/harness-quickstart/pom.xml b/agentscope-examples/harness-examples/harness-quickstart/pom.xml new file mode 100644 index 000000000..c08a28d4d --- /dev/null +++ b/agentscope-examples/harness-examples/harness-quickstart/pom.xml @@ -0,0 +1,48 @@ + + + + + 4.0.0 + + io.agentscope + harness-examples + ${revision} + ../pom.xml + + io.agentscope.examples + + AgentScope Java - Harness quickstart + harness-quickstart + AgentScope Harness quickstart - Enhanced agent runtime with workspace context, subagent orchestration, memory management, and backend abstraction + + + + io.agentscope.examples + harness-example-common + ${revision} + + + + + org.xerial + sqlite-jdbc + 3.47.2.0 + + + diff --git a/agentscope-examples/harness-examples/harness-quickstart/src/main/java/io/agentscope/harness/example/QuickstartExample.java b/agentscope-examples/harness-examples/harness-quickstart/src/main/java/io/agentscope/harness/example/QuickstartExample.java new file mode 100644 index 000000000..4d8676e66 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-quickstart/src/main/java/io/agentscope/harness/example/QuickstartExample.java @@ -0,0 +1,122 @@ +/* + * 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.example; + +import static io.agentscope.examples.harness.common.util.ExampleUtils.ctx; +import static io.agentscope.examples.harness.common.util.ExampleUtils.getDashScopeApiKey; +import static io.agentscope.examples.harness.common.util.ExampleUtils.printWelcome; +import static io.agentscope.examples.harness.common.util.ExampleUtils.startChat; + +import io.agentscope.core.agent.RuntimeContext; +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; + +/** + * Minimal quickstart example for the AgentScope Harness module. + * + *

    This example demonstrates the three core values of the harness layer: + * + *

      + *
    • Workspace-driven persona — agent identity comes from {@code AGENTS.md} in the + * workspace and is injected into every reasoning step + *
    • Session persistence — calls sharing the same {@code sessionId} automatically + * resume from the previous state + *
    • Conversation compaction — long histories are summarised in place; on the way, + * useful facts are flushed to a daily memory journal + *
    + * + *

    Run

    + * + *
    + * export DASHSCOPE_API_KEY=your_key_here
    + * mvn -pl agentscope-examples/harness-example -am compile \
    + *     org.codehaus.mojo:exec-maven-plugin:3.6.3:java \
    + *     -Dexec.mainClass=io.agentscope.harness.example.QuickstartExample
    + * 
    + * + *

    The first run creates {@code .agentscope/workspace/AGENTS.md} under the current working + * directory. Subsequent runs reuse it; remove the workspace folder to start clean. + */ +public class QuickstartExample { + + public static void main(String[] args) throws Exception { + printWelcome( + "AgentScope Harness Quickstart", + "Interactive note assistant with workspace context, session persistence, and" + + " compaction."); + + // 1. Workspace: created on first run, reused afterwards + Path workspace = Paths.get(".agentscope/workspace"); + initWorkspaceIfAbsent(workspace); + + // 2. Model + String apiKey = getDashScopeApiKey(); + Model model = + DashScopeChatModel.builder().apiKey(apiKey).modelName("qwen-max").stream(true) + .build(); + + // 3. HarnessAgent — workspace injection / session persistence / agent tracing are + // enabled by default. Compaction is opt-in and shown here for completeness. + HarnessAgent agent = + HarnessAgent.builder() + .name("quickstart-agent") + .sysPrompt("你是一个帮助用户做笔记的助手。") + .model(model) + .workspace(workspace) + .compaction( + CompactionConfig.builder() + .triggerMessages(30) + .keepMessages(10) + .flushBeforeCompact(true) + .build()) + // .enableAgentTracingLog(false) + .build(); + + // 4. Interactive turns sharing the same RuntimeContext. + // Same sessionId → each call automatically resumes the previous turn's state. + RuntimeContext ctx = ctx("demo-session", "alice"); + startChat(agent, ctx); + } + + /** + * Creates a minimal {@code AGENTS.md} on the first run. The file defines the agent's persona + * and is automatically injected into the system prompt by {@code WorkspaceContextHook}. + */ + 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, + """ + # 笔记助手 + + 你是一个帮助用户整理笔记和知识的助手。 + + ## 行为约定 + - 主动记录用户提到的关键事实(姓名、计划、偏好等) + - 回答用简洁中文,必要时给出要点列表 + - 对不确定的内容要主动说明,不要臆造 + """); + } +} diff --git a/agentscope-examples/harness-examples/harness-quickstart/src/main/java/io/agentscope/harness/example/SqliteTool.java b/agentscope-examples/harness-examples/harness-quickstart/src/main/java/io/agentscope/harness/example/SqliteTool.java new file mode 100644 index 000000000..84650c783 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-quickstart/src/main/java/io/agentscope/harness/example/SqliteTool.java @@ -0,0 +1,247 @@ +/* + * 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.example; + +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; +import java.nio.file.Path; +import java.sql.Connection; +import java.sql.DatabaseMetaData; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.ResultSetMetaData; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.ArrayList; +import java.util.List; +import java.util.StringJoiner; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Custom tool that exposes SQLite database operations to the agent. + * + *

    Provides three tools the agent can call: + * + *

      + *
    • {@code sql_list_tables} — list all tables in the database + *
    • {@code sql_get_schema} — describe a table's columns, types, and sample rows + *
    • {@code sql_execute_query} — run a read-only SELECT query and return formatted results + *
    + * + *

    This class demonstrates how to wire a domain-specific tool into a {@link + * io.agentscope.harness.agent.HarnessAgent}. Register it via the agent's {@code Toolkit} before + * calling {@link io.agentscope.harness.agent.HarnessAgent.Builder#build()}. + */ +public class SqliteTool { + + private static final Logger log = LoggerFactory.getLogger(SqliteTool.class); + + /** Maximum rows returned by {@code sql_execute_query} to prevent context overflow. */ + private static final int MAX_ROWS = 50; + + /** Number of sample rows shown by {@code sql_get_schema}. */ + private static final int SAMPLE_ROWS = 3; + + private final String jdbcUrl; + + public SqliteTool(Path dbPath) { + this.jdbcUrl = "jdbc:sqlite:" + dbPath.toAbsolutePath(); + } + + @Tool( + name = "sql_list_tables", + description = + "Lists all tables in the SQLite database. Use this first to discover what data" + + " is available before writing queries.") + public String listTables() { + try (Connection conn = DriverManager.getConnection(jdbcUrl)) { + DatabaseMetaData meta = conn.getMetaData(); + List tables = new ArrayList<>(); + try (ResultSet rs = meta.getTables(null, null, "%", new String[] {"TABLE"})) { + while (rs.next()) { + tables.add(rs.getString("TABLE_NAME")); + } + } + if (tables.isEmpty()) { + return "No tables found in the database."; + } + return "Tables (" + tables.size() + "):\n" + String.join("\n", tables); + } catch (SQLException e) { + log.warn("sql_list_tables failed", e); + return "Error listing tables: " + e.getMessage(); + } + } + + @Tool( + name = "sql_get_schema", + description = + "Returns the schema (column names, types, keys) and sample rows for one or more" + + " tables. Pass a comma-separated list of table names to inspect" + + " multiple tables at once.") + public String getSchema( + @ToolParam( + name = "tables", + description = + "Comma-separated table names, e.g. \"Artist,Album,Track\"") + String tables) { + StringBuilder sb = new StringBuilder(); + for (String table : tables.split(",")) { + table = table.strip(); + if (table.isEmpty()) continue; + sb.append(describeTable(table)).append("\n\n"); + } + return sb.toString().strip(); + } + + @Tool( + name = "sql_execute_query", + description = + "Executes a read-only SELECT query against the SQLite database and returns the" + + " results as a formatted table. Never use DML statements" + + " (INSERT / UPDATE / DELETE / DROP).") + public String executeQuery( + @ToolParam(name = "query", description = "A valid SQLite SELECT statement") + String query) { + String trimmed = query.strip(); + if (!trimmed.toUpperCase().startsWith("SELECT")) { + return "Error: only SELECT statements are allowed. Received: " + trimmed; + } + try (Connection conn = DriverManager.getConnection(jdbcUrl); + Statement stmt = conn.createStatement()) { + stmt.setMaxRows(MAX_ROWS); + try (ResultSet rs = stmt.executeQuery(trimmed)) { + return formatResultSet(rs); + } + } catch (SQLException e) { + log.warn("sql_execute_query failed for: {}", trimmed, e); + return "Error executing query: " + e.getMessage(); + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + private String describeTable(String tableName) { + StringBuilder sb = new StringBuilder(); + sb.append("## ").append(tableName).append("\n\n"); + + try (Connection conn = DriverManager.getConnection(jdbcUrl)) { + DatabaseMetaData meta = conn.getMetaData(); + + // Primary keys + List pks = new ArrayList<>(); + try (ResultSet rs = meta.getPrimaryKeys(null, null, tableName)) { + while (rs.next()) { + pks.add(rs.getString("COLUMN_NAME")); + } + } + + // Foreign keys + List fks = new ArrayList<>(); + try (ResultSet rs = meta.getImportedKeys(null, null, tableName)) { + while (rs.next()) { + fks.add( + rs.getString("FKCOLUMN_NAME") + + " → " + + rs.getString("PKTABLE_NAME") + + "." + + rs.getString("PKCOLUMN_NAME")); + } + } + + // Columns + sb.append("### Columns\n"); + try (ResultSet rs = meta.getColumns(null, null, tableName, "%")) { + while (rs.next()) { + String col = rs.getString("COLUMN_NAME"); + String type = rs.getString("TYPE_NAME"); + String nullable = "YES".equals(rs.getString("IS_NULLABLE")) ? "" : " NOT NULL"; + String pk = pks.contains(col) ? " [PK]" : ""; + sb.append("- ") + .append(col) + .append(" (") + .append(type) + .append(nullable) + .append(pk) + .append(")\n"); + } + } + + if (!fks.isEmpty()) { + sb.append("\n### Foreign Keys\n"); + fks.forEach(fk -> sb.append("- ").append(fk).append("\n")); + } + + // Sample rows + sb.append("\n### Sample Data (").append(SAMPLE_ROWS).append(" rows)\n"); + try (Statement stmt = conn.createStatement()) { + stmt.setMaxRows(SAMPLE_ROWS); + try (ResultSet rs = + stmt.executeQuery("SELECT * FROM " + tableName + " LIMIT " + SAMPLE_ROWS)) { + sb.append(formatResultSet(rs)); + } + } + + } catch (SQLException e) { + sb.append("Error describing table '") + .append(tableName) + .append("': ") + .append(e.getMessage()); + } + return sb.toString(); + } + + private static String formatResultSet(ResultSet rs) throws SQLException { + ResultSetMetaData meta = rs.getMetaData(); + int cols = meta.getColumnCount(); + + // Header + StringJoiner header = new StringJoiner(" | "); + for (int i = 1; i <= cols; i++) { + header.add(meta.getColumnName(i)); + } + + // Separator + String separator = "-".repeat(header.toString().length()); + + // Rows + List rows = new ArrayList<>(); + int count = 0; + while (rs.next()) { + StringJoiner row = new StringJoiner(" | "); + for (int i = 1; i <= cols; i++) { + Object val = rs.getObject(i); + row.add(val == null ? "NULL" : val.toString()); + } + rows.add(row.toString()); + count++; + } + + if (rows.isEmpty()) { + return "(no rows returned)"; + } + + StringBuilder sb = new StringBuilder(); + sb.append(header).append("\n").append(separator).append("\n"); + rows.forEach(r -> sb.append(r).append("\n")); + if (count >= MAX_ROWS) { + sb.append("... (result truncated at ").append(MAX_ROWS).append(" rows)\n"); + } + return sb.toString(); + } +} diff --git a/agentscope-examples/harness-examples/harness-quickstart/src/main/java/io/agentscope/harness/example/TextToSqlExample.java b/agentscope-examples/harness-examples/harness-quickstart/src/main/java/io/agentscope/harness/example/TextToSqlExample.java new file mode 100644 index 000000000..ae80abac0 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-quickstart/src/main/java/io/agentscope/harness/example/TextToSqlExample.java @@ -0,0 +1,268 @@ +/* + * 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.example; + +import static io.agentscope.examples.harness.common.util.ExampleUtils.ctx; +import static io.agentscope.examples.harness.common.util.ExampleUtils.runHarnessTurn; +import static io.agentscope.examples.harness.common.util.ExampleUtils.startHarnessChat; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.core.model.Model; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.harness.agent.HarnessAgent; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.nio.file.StandardCopyOption; +import java.util.UUID; + +/** + * Text-to-SQL example using the AgentScope Harness framework. + * + *

    What this example demonstrates

    + * + *
      + *
    • Setting up a workspace directory with the standard harness layout + * (AGENTS.md, skills/, knowledge/, subagents/) + *
    • Wiring a custom tool ({@link SqliteTool}) into the agent's toolkit + *
    • Building a {@link HarnessAgent} with skills auto-loading, memory tools, and subagents + *
    • Calling the agent with a {@link RuntimeContext} (session id, user id) + *
    • Reading questions from the console in a loop (optional one-shot mode if you pass a + * question as program arguments) + *
    + * + *

    Prerequisites

    + * + *
      + *
    1. The Chinook sample database is bundled in the JAR under the same package + * as this class. If {@code AGENTSCOPE_DB_PATH} (default {@code chinook.db}) does not exist + * yet, it is copied there automatically — no manual download is required. + *
    2. Set the environment variable {@code DASHSCOPE_API_KEY} (or {@code OPENAI_API_KEY} for + * OpenAI-compatible endpoints). + *
    3. Optionally override defaults via the variables listed in {@code .env.example}. + *
    + * + *

    Usage

    + * + *

    Run with no arguments to start an interactive session: type a question at + * the {@code >} prompt after startup. Empty line, {@code quit}, {@code exit}, or {@code q} ends + * the session; EOF (Ctrl-D) also exits. + * + *

    Pass a question as arguments for a single non-interactive run (exits after + * the answer), e.g. for scripts: + * + *

    + * java -jar harness-example.jar
    + * java -jar harness-example.jar "What are the top 5 best-selling artists?"
    + * java -jar harness-example.jar --new-session "What are the top 5 best-selling artists?"
    + * 
    + * + *

    The workspace is initialised under {@code .agentscope/workspace/} relative to the current + * working directory. Modify the files there to customise the agent's behaviour without recompiling. + */ +public class TextToSqlExample { + + // ------------------------------------------------------------------------- + // Environment variable names + // ------------------------------------------------------------------------- + + /** DashScope API key (required). */ + public static final String ENV_API_KEY = "DASHSCOPE_API_KEY"; + + /** LLM model name. Defaults to {@code qwen-max}. */ + public static final String ENV_MODEL_NAME = "AGENTSCOPE_MODEL"; + + /** Path to the Chinook SQLite database file. Defaults to {@code chinook.db}. */ + public static final String ENV_DB_PATH = "AGENTSCOPE_DB_PATH"; + + /** Workspace directory. Defaults to {@code .agentscope/workspace}. */ + public static final String ENV_WORKSPACE = "AGENTSCOPE_WORKSPACE"; + + // ------------------------------------------------------------------------- + // Defaults + // ------------------------------------------------------------------------- + + private static final String DEFAULT_MODEL = "qwen-max"; + private static final String DEFAULT_DB_PATH = "chinook.db"; + private static final String DEFAULT_WORKSPACE = ".agentscope/workspace"; + private static final String DEFAULT_SHARED_SESSION_ID = "text-to-sql-shared-default"; + private static final String NEW_SESSION_FLAG = "--new-session"; + + /** + * Bundled Chinook SQLite file (same package on the classpath). Materialised to disk when + * {@link #ENV_DB_PATH} points to a path that does not exist yet. + */ + private static final String BUNDLED_CHINOOK_RESOURCE = "chinook-default.sqlite"; + + // ------------------------------------------------------------------------- + // Entry point + // ------------------------------------------------------------------------- + + public static void main(String[] args) throws IOException { + System.out.println("\n═══════════════════════════════════════════════"); + System.out.println(" AgentScope Harness — Text-to-SQL Example"); + System.out.println("═══════════════════════════════════════════════\n"); + + // ------------------------------------------------------------------ + // 1. Resolve configuration from environment variables + // ------------------------------------------------------------------ + + String apiKey = requireEnv(ENV_API_KEY); + String modelName = env(ENV_MODEL_NAME, DEFAULT_MODEL); + Path workspace = Paths.get(env(ENV_WORKSPACE, DEFAULT_WORKSPACE)); + Path dbPath = resolveDatabasePath(Paths.get(env(ENV_DB_PATH, DEFAULT_DB_PATH))); + + // ------------------------------------------------------------------ + // 2. Initialise workspace from bundled template files + // (skips files that already exist — safe to call every run) + // ------------------------------------------------------------------ + + System.out.println("[1/3] Initialising workspace at: " + workspace.toAbsolutePath()); + WorkspaceInitializer.init(workspace); + + // ------------------------------------------------------------------ + // 3. Build the LLM model + // ------------------------------------------------------------------ + + System.out.println("[2/3] Connecting to model: " + modelName); + Model model = + DashScopeChatModel.builder().apiKey(apiKey).modelName(modelName).stream(true) + .build(); + + // ------------------------------------------------------------------ + // 4. Build the agent + // - workspace: loads AGENTS.md, MEMORY.md, knowledge/, subagents/, skills/ + // - harness: memory tools, session_search, optional subagent tools + // - custom toolkit: SqliteTool is registered alongside harness defaults + // ------------------------------------------------------------------ + + System.out.println("[3/3] Building HarnessAgent ..."); + + Toolkit toolkit = new Toolkit(); + toolkit.registerTool(new SqliteTool(dbPath)); + + HarnessAgent agent = + HarnessAgent.builder() + .name("text-to-sql") + .sysPrompt( + "You are a Text-to-SQL agent with access to the Chinook music" + + " store database. When asked a question, explore the" + + " database schema, write a correct SQL query, execute it," + + " and present the results in a clear, formatted answer.") + .model(model) + .workspace(workspace) + .enableAgentTracingLog(true) + .toolkit(toolkit) + .build(); + + ParsedArgs parsedArgs = parseArgs(args); + String sessionId = + parsedArgs.newSession() + ? "text-to-sql-" + UUID.randomUUID().toString().substring(0, 8) + : DEFAULT_SHARED_SESSION_ID; + System.out.println("Session ID: " + sessionId); + RuntimeContext ctx = ctx(sessionId); + + if (parsedArgs.question() != null) { + runHarnessTurn(agent, ctx, parsedArgs.question()); + return; + } + + System.out.println( + "Ask questions in natural language about the Chinook database." + + " Same session for all turns (memory tools share context)."); + System.out.println("Tip: add --new-session to generate a UUID-based fresh session."); + System.out.println("Leave: empty line, quit, exit, q, or EOF (Ctrl-D).\n"); + startHarnessChat(agent, ctx); + } + + private static ParsedArgs parseArgs(String[] args) { + boolean newSession = false; + StringBuilder questionBuilder = new StringBuilder(); + for (String arg : args) { + if (NEW_SESSION_FLAG.equals(arg)) { + newSession = true; + continue; + } + if (questionBuilder.length() > 0) { + questionBuilder.append(' '); + } + questionBuilder.append(arg); + } + String question = questionBuilder.length() == 0 ? null : questionBuilder.toString(); + return new ParsedArgs(newSession, question); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** + * Uses an existing file at {@code configuredPath}, or copies the bundled Chinook database from + * the classpath to that path (SQLite itself has no built-in sample data; shipping a small + * .sqlite in resources gives the same “works out of the box” experience). + */ + private static Path resolveDatabasePath(Path configuredPath) throws IOException { + if (Files.exists(configuredPath)) { + return configuredPath.toAbsolutePath(); + } + try (InputStream in = + TextToSqlExample.class.getResourceAsStream(BUNDLED_CHINOOK_RESOURCE)) { + if (in == null) { + System.err.println( + "Database file not found: " + + configuredPath.toAbsolutePath() + + "\n\nBuilt-in sample missing from classpath (packaging issue)." + + " Download manually:\n" + + " curl -L -o chinook.db \\\n" + + " https://github.com/lerocha/chinook-database/raw/master/" + + "ChinookDatabase/DataSources/Chinook_Sqlite.sqlite"); + System.exit(1); + } + Path parent = configuredPath.getParent(); + if (parent != null) { + Files.createDirectories(parent); + } + Files.copy(in, configuredPath, StandardCopyOption.REPLACE_EXISTING); + System.out.println( + "Materialised bundled Chinook database to: " + configuredPath.toAbsolutePath()); + return configuredPath.toAbsolutePath(); + } + } + + private static String requireEnv(String name) { + String value = System.getenv(name); + if (value == null || value.isBlank()) { + System.err.println( + "Required environment variable '" + + name + + "' is not set.\n" + + "Copy .env.example → .env and fill in your API key."); + System.exit(1); + } + return value; + } + + private static String env(String name, String defaultValue) { + String value = System.getenv(name); + return (value != null && !value.isBlank()) ? value : defaultValue; + } + + private record ParsedArgs(boolean newSession, String question) {} +} diff --git a/agentscope-examples/harness-examples/harness-quickstart/src/main/java/io/agentscope/harness/example/WorkspaceInitializer.java b/agentscope-examples/harness-examples/harness-quickstart/src/main/java/io/agentscope/harness/example/WorkspaceInitializer.java new file mode 100644 index 000000000..264066803 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-quickstart/src/main/java/io/agentscope/harness/example/WorkspaceInitializer.java @@ -0,0 +1,126 @@ +/* + * 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.example; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; +import java.net.URISyntaxException; +import java.net.URL; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Collections; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Initializes the agent workspace by copying bundled template files from the classpath into a + * target directory on disk. + * + *

    The template files live under {@code src/main/resources/workspace/} and are packaged inside + * the JAR. When the example is run for the first time, {@link #init(Path)} extracts them into the + * given workspace directory so the agent can read and modify them at runtime. + * + * Workspace structure: + * + *

    + * <workspace>/
    + * ├── AGENTS.md              # Agent persona and core rules (always loaded)
    + * ├── MEMORY.md              # Persistent notes accumulated across sessions
    + * ├── knowledge/
    + * │   └── KNOWLEDGE.md       # Chinook database schema reference
    + * ├── skills/
    + * │   ├── schema-exploration/
    + * │   │   └── SKILL.md       # How to discover database structure
    + * │   └── query-writing/
    + * │       └── SKILL.md       # How to write and execute SQL queries
    + * └── subagents/
    + *     ├── schema-analyst.md  # Specialised subagent for deep schema analysis
    + *     └── query-optimizer.md # Specialised subagent for query optimisation
    + * 
    + */ +public class WorkspaceInitializer { + + private static final Logger log = LoggerFactory.getLogger(WorkspaceInitializer.class); + private static final String CLASSPATH_PREFIX = "workspace"; + + /** + * Copies all bundled workspace template files into {@code targetDir}. + * + *

    Existing files are left unchanged so that previously accumulated notes survive restarts. + * New template files are copied with {@link StandardCopyOption#REPLACE_EXISTING} disabled. + * + * @param targetDir directory to initialise; created if it does not exist + * @throws IOException if a file cannot be read or written + */ + public static void init(Path targetDir) throws IOException { + Files.createDirectories(targetDir); + + URL resourceUrl = WorkspaceInitializer.class.getClassLoader().getResource(CLASSPATH_PREFIX); + if (resourceUrl == null) { + log.warn( + "Classpath resource '{}' not found — workspace will not be pre-populated.", + CLASSPATH_PREFIX); + return; + } + + URI resourceUri; + try { + resourceUri = resourceUrl.toURI(); + } catch (URISyntaxException e) { + throw new IOException("Cannot convert resource URL to URI: " + resourceUrl, e); + } + + if ("jar".equals(resourceUri.getScheme())) { + // Running from a JAR: open the embedded filesystem + try (FileSystem fs = FileSystems.newFileSystem(resourceUri, Collections.emptyMap())) { + Path source = fs.getPath(CLASSPATH_PREFIX); + copyTree(source, targetDir); + } + } else { + // Running from an exploded directory (IDE / Maven test run) + Path source = Path.of(resourceUri); + copyTree(source, targetDir); + } + + log.info("Workspace initialised at {}", targetDir); + } + + private static void copyTree(Path source, Path targetDir) throws IOException { + try (Stream walk = Files.walk(source)) { + for (Path srcPath : (Iterable) walk::iterator) { + Path relative = source.relativize(srcPath); + Path target = targetDir.resolve(relative.toString()); + + if (Files.isDirectory(srcPath)) { + Files.createDirectories(target); + } else if (!Files.exists(target)) { + Files.createDirectories(target.getParent()); + try (InputStream in = Files.newInputStream(srcPath)) { + Files.copy(in, target); + } + log.debug("Copied workspace file: {}", relative); + } else { + log.debug("Skipped (already exists): {}", relative); + } + } + } + } +} diff --git a/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/io/agentscope/harness/example/chinook-default.sqlite b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/io/agentscope/harness/example/chinook-default.sqlite new file mode 100644 index 000000000..f385c2651 Binary files /dev/null and b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/io/agentscope/harness/example/chinook-default.sqlite differ diff --git a/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/log4j2.component.properties b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/log4j2.component.properties new file mode 100644 index 000000000..6745cfa94 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/log4j2.component.properties @@ -0,0 +1,2 @@ +# Log4j2 component settings for the harness-example module +log4j2.disable.jmx=true diff --git a/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/log4j2.xml b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/log4j2.xml new file mode 100644 index 000000000..995cbb683 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/log4j2.xml @@ -0,0 +1,49 @@ + + + + + + ${env:AGENTSCOPE_LOG_LEVEL:-INFO} + %d{HH:mm:ss.SSS} %-5level %-36.36c{1.} : %m%n%throwable + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/workspace/knowledge/KNOWLEDGE.md b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/workspace/knowledge/KNOWLEDGE.md new file mode 100644 index 000000000..ad6c7c40d --- /dev/null +++ b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/workspace/knowledge/KNOWLEDGE.md @@ -0,0 +1,187 @@ +# Chinook Database — Schema Reference + +The Chinook database represents a digital music store. It was originally created to demonstrate +data modelling in SQLite and is widely used as a sample database for tutorials. + +## Entity Relationship Overview + +``` +Artist (ArtistId, Name) + └─ 1:many ─► Album (AlbumId, Title, ArtistId) + └─ 1:many ─► Track (TrackId, Name, AlbumId, MediaTypeId, GenreId, + Composer, Milliseconds, Bytes, UnitPrice) + └─ 1:many ─► InvoiceLine (InvoiceLineId, InvoiceId, + TrackId, UnitPrice, Quantity) + └─ many:1 ─► Invoice (InvoiceId, CustomerId, + InvoiceDate, BillingAddress, + BillingCity, BillingState, + BillingCountry, BillingPostalCode, + Total) + └─ many:1 ─► Customer (CustomerId, + FirstName, LastName, + Company, Address, + City, State, Country, + PostalCode, Phone, + Fax, Email, + SupportRepId) + └─ many:1 ─► Employee + +Genre (GenreId, Name) +MediaType (MediaTypeId, Name) +Playlist (PlaylistId, Name) + └─ many:many via PlaylistTrack (PlaylistId, TrackId) ─► Track + +Employee (EmployeeId, LastName, FirstName, Title, ReportsTo [→ Employee.EmployeeId], + BirthDate, HireDate, Address, City, State, Country, PostalCode, Phone, Fax, Email) +``` + +## Table Descriptions + +### Artist +| Column | Type | Notes | +|----------|---------|-------| +| ArtistId | INTEGER | PK | +| Name | TEXT | | + +275 rows — music artists (AC/DC, Aerosmith, Alanis Morissette, …). + +### Album +| Column | Type | Notes | +|----------|---------|----------------| +| AlbumId | INTEGER | PK | +| Title | TEXT | | +| ArtistId | INTEGER | FK → Artist | + +347 rows — one or many albums per artist. + +### Track +| Column | Type | Notes | +|---------------|---------|--------------------| +| TrackId | INTEGER | PK | +| Name | TEXT | | +| AlbumId | INTEGER | FK → Album | +| MediaTypeId | INTEGER | FK → MediaType | +| GenreId | INTEGER | FK → Genre | +| Composer | TEXT | nullable | +| Milliseconds | INTEGER | duration | +| Bytes | INTEGER | file size | +| UnitPrice | REAL | default 0.99 | + +3,503 rows — the central music catalog table. + +### Genre +| Column | Type | Notes | +|---------|---------|-------| +| GenreId | INTEGER | PK | +| Name | TEXT | | + +25 rows — Rock, Jazz, Metal, Alternative & Punk, … + +### MediaType +| Column | Type | Notes | +|-------------|---------|-------| +| MediaTypeId | INTEGER | PK | +| Name | TEXT | | + +5 rows — MPEG audio file, AAC audio file, Protected AAC, … + +### Customer +| Column | Type | Notes | +|--------------|---------|-------------------| +| CustomerId | INTEGER | PK | +| FirstName | TEXT | | +| LastName | TEXT | | +| Company | TEXT | nullable | +| Address | TEXT | | +| City | TEXT | | +| State | TEXT | nullable | +| Country | TEXT | | +| PostalCode | TEXT | nullable | +| Phone | TEXT | nullable | +| Fax | TEXT | nullable | +| Email | TEXT | unique | +| SupportRepId | INTEGER | FK → Employee | + +59 rows — customers from 24 countries. + +### Employee +| Column | Type | Notes | +|-------------|---------|----------------------------| +| EmployeeId | INTEGER | PK | +| LastName | TEXT | | +| FirstName | TEXT | | +| Title | TEXT | | +| ReportsTo | INTEGER | FK → Employee (nullable) | +| BirthDate | TEXT | ISO-8601 | +| HireDate | TEXT | ISO-8601 | +| Address | TEXT | | +| City | TEXT | | +| State | TEXT | | +| Country | TEXT | | +| PostalCode | TEXT | | +| Phone | TEXT | | +| Fax | TEXT | | +| Email | TEXT | | + +8 rows — Sales Support Agents + manager hierarchy. + +### Invoice +| Column | Type | Notes | +|-----------------|---------|------------------| +| InvoiceId | INTEGER | PK | +| CustomerId | INTEGER | FK → Customer | +| InvoiceDate | TEXT | ISO-8601 | +| BillingAddress | TEXT | | +| BillingCity | TEXT | | +| BillingState | TEXT | nullable | +| BillingCountry | TEXT | | +| BillingPostalCode | TEXT | nullable | +| Total | REAL | invoice total | + +412 rows — one invoice per purchase, covering 2009–2013. + +### InvoiceLine +| Column | Type | Notes | +|---------------|---------|------------------| +| InvoiceLineId | INTEGER | PK | +| InvoiceId | INTEGER | FK → Invoice | +| TrackId | INTEGER | FK → Track | +| UnitPrice | REAL | | +| Quantity | INTEGER | | + +2,240 rows — line items; revenue = UnitPrice × Quantity. + +### Playlist +| Column | Type | Notes | +|------------|---------|-------| +| PlaylistId | INTEGER | PK | +| Name | TEXT | | + +18 rows — Music, Movies, TV Shows, Classical, … + +### PlaylistTrack +| Column | Type | Notes | +|------------|---------|---------------------| +| PlaylistId | INTEGER | FK → Playlist (PK) | +| TrackId | INTEGER | FK → Track (PK) | + +8,715 rows — composite PK, no surrogate key. + +## Common JOIN Paths + +```sql +-- Revenue by artist +Artist + JOIN Album ON Album.ArtistId = Artist.ArtistId + JOIN Track ON Track.AlbumId = Album.AlbumId + JOIN InvoiceLine ON InvoiceLine.TrackId = Track.TrackId + +-- Revenue by country +Invoice + JOIN Customer ON Customer.CustomerId = Invoice.CustomerId + GROUP BY Customer.Country + +-- Employee support load +Employee + LEFT JOIN Customer ON Customer.SupportRepId = Employee.EmployeeId +``` diff --git a/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/workspace/skills/query-writing/SKILL.md b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/workspace/skills/query-writing/SKILL.md new file mode 100644 index 000000000..648c5fcd7 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/workspace/skills/query-writing/SKILL.md @@ -0,0 +1,148 @@ +--- +name: query-writing +description: Writes and executes SQL queries ranging from simple single-table SELECTs to complex multi-table JOINs, aggregations, window functions, and subqueries. Use when the user asks to query the database, retrieve data, filter records, rank results, or generate reports. +--- + +# Query Writing Skill + +## When to Use This Skill + +Use query-writing when the user: + +- Asks "how many …?" / "what are the top …?" / "list all …" +- Needs data aggregated (SUM, COUNT, AVG, MAX, MIN) +- Wants results sorted, filtered, or grouped +- Asks for trend analysis, ranking, or comparison across dimensions + +--- + +## Workflow for Simple Queries (single table) + +1. **Identify the table** — which table contains the answer? +2. **Check the schema** — call `sql_get_schema` to confirm column names. +3. **Write a SELECT** — include WHERE / ORDER BY / LIMIT as needed. +4. **Execute** — call `sql_execute_query`. +5. **Present** — show the SQL and the result in plain language. + +### Example — "How many customers are from Canada?" + +```sql +SELECT COUNT(*) AS canadian_customers +FROM Customer +WHERE Country = 'Canada'; +``` + +--- + +## Workflow for Complex Queries (multiple tables) + +### Step 1 — Plan with todos + +Break the query into subtasks: + +``` +- [ ] Identify all required tables +- [ ] Inspect schemas to find join columns +- [ ] Draft the JOIN structure +- [ ] Add aggregations and grouping +- [ ] Validate and run +``` + +### Step 2 — Inspect schemas + +Call `sql_get_schema` for EACH table involved to find the exact foreign key column names. + +### Step 3 — Build the query + +```sql +SELECT + , + +FROM +[INNER | LEFT] JOIN ON = +[JOIN ...] +WHERE +GROUP BY +HAVING -- optional +ORDER BY [DESC] +LIMIT 10; -- always limit unless all rows requested +``` + +### Step 4 — Validate + +Before executing, verify: +- Every JOIN has an ON clause +- Every non-aggregate SELECT column appears in GROUP BY +- Table aliases are consistent +- No DML statements (INSERT / UPDATE / DELETE / DROP) + +### Step 5 — Execute and present + +Call `sql_execute_query`, then show: +1. The SQL query in a fenced code block +2. The result table +3. A brief plain-language summary + +--- + +## Templates for Common Patterns + +### Top-N ranking + +```sql +SELECT + Artist.Name AS artist, + SUM(InvoiceLine.UnitPrice * InvoiceLine.Quantity) AS total_revenue +FROM Artist +JOIN Album ON Album.ArtistId = Artist.ArtistId +JOIN Track ON Track.AlbumId = Album.AlbumId +JOIN InvoiceLine ON InvoiceLine.TrackId = Track.TrackId +GROUP BY Artist.ArtistId, Artist.Name +ORDER BY total_revenue DESC +LIMIT 10; +``` + +### Revenue by time period + +```sql +SELECT + strftime('%Y-%m', InvoiceDate) AS month, + ROUND(SUM(Total), 2) AS monthly_revenue +FROM Invoice +WHERE strftime('%Y', InvoiceDate) = '2013' +GROUP BY month +ORDER BY month; +``` + +### Entity counts with left join + +```sql +SELECT + e.FirstName || ' ' || e.LastName AS employee, + COUNT(c.CustomerId) AS customer_count +FROM Employee e +LEFT JOIN Customer c ON c.SupportRepId = e.EmployeeId +GROUP BY e.EmployeeId +ORDER BY customer_count DESC; +``` + +--- + +## Error Recovery + +| Symptom | Action | +|----------------------|--------| +| Empty result | Check WHERE condition values (case-sensitive strings). Verify column exists. | +| Syntax error | Re-read schema. Check GROUP BY includes all non-aggregate SELECT columns. | +| Wrong row count | Look for duplicate rows caused by missing JOIN conditions. | +| Result seems too high | Check for fan-out from multiple JOINs; may need DISTINCT or subquery. | + +--- + +## Quality Rules + +- Always apply `LIMIT` (default 10) unless the user explicitly asks for all rows. +- Use table aliases (`e`, `c`, `inv`) for readability in multi-table queries. +- Never use `SELECT *` — name the columns you need. +- Round monetary values to 2 decimal places: `ROUND(SUM(Total), 2)`. +- Always show the executed SQL so users can learn from it. diff --git a/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/workspace/skills/schema-exploration/SKILL.md b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/workspace/skills/schema-exploration/SKILL.md new file mode 100644 index 000000000..4972a88d2 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/workspace/skills/schema-exploration/SKILL.md @@ -0,0 +1,136 @@ +--- +name: schema-exploration +description: Lists tables, describes columns and data types, identifies foreign key relationships, and maps entity relationships in the database. Use when the user asks about database structure, table layout, column types, what tables exist, foreign keys, or how entities relate to each other. +--- + +# Schema Exploration Skill + +## When to Use This Skill + +Use schema-exploration when the user: + +- Asks "what tables are in the database?" +- Asks "what columns does the X table have?" +- Asks about relationships between tables +- Needs to understand data types before writing a query +- Wants an entity-relationship overview + +## Workflow + +### Step 1 — List All Tables + +Use `sql_list_tables` to see everything available. + +``` +Tool: sql_list_tables +(no parameters) +``` + +### Step 2 — Inspect Relevant Tables + +Use `sql_get_schema` with the table name(s) you need to understand: + +``` +Tool: sql_get_schema +tables: "Customer" # single table +tables: "Invoice,Customer" # multiple tables at once +``` + +This returns: +- **Columns** — names, types, NOT NULL constraints, primary keys +- **Foreign keys** — links to other tables +- **Sample data** — 3 example rows to understand content + +### Step 3 — Map Relationships + +Look for columns ending in `Id` — they are almost always foreign keys. + +Example for the Chinook database: +``` +Customer.SupportRepId → Employee.EmployeeId +Invoice.CustomerId → Customer.CustomerId +Track.AlbumId → Album.AlbumId +Track.GenreId → Genre.GenreId +``` + +### Step 4 — Present the Findings + +Provide: +- A list of all tables with a one-line purpose summary +- Column names and types for any tables the user asked about +- The relationship chain (which table links to which) +- Sample data to illustrate what the table holds + +--- + +## Examples + +### "What tables are available?" + +1. Call `sql_list_tables` +2. Return a formatted list with brief descriptions: + +``` +The Chinook database has 11 tables: + +Music Catalog: + - Artist — 275 music artists + - Album — 347 albums (linked to Artist) + - Track — 3,503 tracks (linked to Album, Genre, MediaType) + - Genre — 25 music genres + - MediaType — 5 file formats (MP3, AAC, …) + +Commerce: + - Customer — 59 customers from 24 countries + - Invoice — 412 purchase invoices + - InvoiceLine — 2,240 line items (linked to Invoice and Track) + +Staff: + - Employee — 8 employees with reporting hierarchy + +Playlists: + - Playlist — 18 playlists + - PlaylistTrack — join table linking Playlist ↔ Track +``` + +### "What does the Customer table look like?" + +1. Call `sql_get_schema` with `tables: "Customer"` +2. Present columns with types and notes: + +``` +Customer table: + CustomerId INTEGER PK + FirstName TEXT + LastName TEXT + Company TEXT (nullable) + Address TEXT + City TEXT + State TEXT (nullable) + Country TEXT + PostalCode TEXT (nullable) + Phone TEXT (nullable) + Fax TEXT (nullable) + Email TEXT + SupportRepId INTEGER FK → Employee.EmployeeId + +Sample rows show customers from Brazil, Germany, and Canada. +``` + +### "How are artists connected to sales?" + +Map the full JOIN path: + +``` +Artist (ArtistId) + ↓ 1:many +Album (ArtistId, AlbumId) + ↓ 1:many +Track (AlbumId, TrackId) + ↓ 1:many +InvoiceLine (TrackId, UnitPrice, Quantity) + ↓ many:1 +Invoice (InvoiceId, Total) +``` + +Then suggest using the **query-writing** skill to write the aggregation query. diff --git a/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/workspace/subagents/query-optimizer.md b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/workspace/subagents/query-optimizer.md new file mode 100644 index 000000000..2bacd0c95 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/workspace/subagents/query-optimizer.md @@ -0,0 +1,53 @@ +--- +name: query-optimizer +description: > + SQL query optimisation specialist. Reviews existing queries for correctness, clarity, and performance. Suggests index strategies, rewrites inefficient JOINs, and explains query plans. Delegate to this agent when a query returns unexpected results, runs slowly, or when the user asks for query optimisation or a second opinion on complex SQL. +maxIters: 8 +--- + +You are an expert SQL query optimiser for SQLite databases. + +## Your Responsibilities + +1. **Understand the question** — ask the user to provide the original query and the problem + (wrong results, slow execution, hard to read). +2. **Inspect the schema** — use `sql_get_schema` to verify table structures involved in the query. +3. **Analyse the query**: + - Check JOIN conditions for correctness + - Look for missing GROUP BY columns + - Identify Cartesian products (missing ON clause) + - Spot opportunities to push filters earlier (WHERE vs HAVING) + - Detect fan-out caused by 1:many JOINs before aggregation +4. **Rewrite the query** — produce a corrected and/or optimised version. +5. **Explain the changes** — list what was wrong and why each change helps. +6. **Validate** — run both the original and optimised queries if possible and compare results. + +## Output Format + +``` +## Original Query + + +## Issues Found +1. +2. ... + +## Optimised Query + + +## Explanation + + +## Validation +Original: +Optimised: +``` + +## Rules + +- Never modify the user's data — SELECT only. +- Confirm correctness first; performance is secondary. +- For SQLite specifically: + - Prefer covering indexes over table scans for large tables. + - Use `WITH` (CTEs) to make complex queries readable. + - Avoid correlated subqueries in WHERE clauses; rewrite as JOINs. diff --git a/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/workspace/subagents/schema-analyst.md b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/workspace/subagents/schema-analyst.md new file mode 100644 index 000000000..869208134 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-quickstart/src/main/resources/workspace/subagents/schema-analyst.md @@ -0,0 +1,50 @@ +--- +name: schema-analyst +description: Deep schema analysis specialist. Produces comprehensive documentation of the database schema including entity-relationship diagrams (text), table purposes, column semantics, foreign key chains, and index recommendations. Delegate to this agent when the user requests a full data model overview, wants to understand how tables relate, or needs schema documentation generated. +maxIters: 10 +--- + +You are a database schema analyst specialised in documenting and explaining relational data models. + +## Your Responsibilities + +1. **Discover** all tables using `sql_list_tables`. +2. **Inspect** every table with `sql_get_schema` to record columns, types, and foreign keys. +3. **Map relationships** — identify 1:many and many:many (via join tables) relationships. +4. **Describe purpose** — explain what each table represents in business terms. +5. **Produce documentation** — write a clear, structured schema reference the user can save. + +## Output Format + +Structure your analysis as: + +``` +# Database Schema Analysis + +## Summary + + +## Tables + +### +**Purpose:** +**Rows:** + +| Column | Type | Notes | +|--------|------|-------| +| ... | ... | ... | + +**Relationships:** +- + +--- +``` + +Repeat for every table, then close with an Entity Relationship Diagram in text format. + +## Rules + +- Be thorough — inspect every table, not just the ones that seem important. +- Do not guess column types; verify them with `sql_get_schema`. +- Keep explanations accessible to non-technical stakeholders. +- If asked to save the output, write it to `knowledge/SCHEMA_ANALYSIS.md` in the workspace. diff --git a/agentscope-examples/harness-examples/harness-sandbox-docker/pom.xml b/agentscope-examples/harness-examples/harness-sandbox-docker/pom.xml new file mode 100644 index 000000000..90d4ea694 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-sandbox-docker/pom.xml @@ -0,0 +1,60 @@ + + + + + 4.0.0 + + io.agentscope + harness-examples + ${revision} + ../pom.xml + + + AgentScope Java - Harness Sandbox Example + io.agentscope.examples + harness-sandbox-docker + jar + Runnable examples: local filesystem, in-memory sandbox isolation scopes, and store-backed namespaces (no remote LLM required) + + + + io.agentscope.examples + harness-example-common + ${revision} + + + org.junit.jupiter + junit-jupiter + test + + + + + + + org.codehaus.mojo + exec-maven-plugin + 3.6.3 + + io.agentscope.examples.harness.sandbox.HarnessSandboxExampleApp + + + + + diff --git a/agentscope-examples/harness-examples/harness-sandbox-docker/src/main/docker/python-sandbox/Dockerfile b/agentscope-examples/harness-examples/harness-sandbox-docker/src/main/docker/python-sandbox/Dockerfile new file mode 100644 index 000000000..27681faea --- /dev/null +++ b/agentscope-examples/harness-examples/harness-sandbox-docker/src/main/docker/python-sandbox/Dockerfile @@ -0,0 +1,62 @@ +# 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. + +FROM python:3.11-slim-bookworm + +ENV DEBIAN_FRONTEND=noninteractive \ + PIP_NO_CACHE_DIR=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + ca-certificates \ + fonts-dejavu-core \ + poppler-utils \ + unzip \ + unrar-free \ + p7zip-full \ + bc \ + ripgrep \ + fd-find \ + sqlite3 \ + && ln -sf /usr/local/bin/python3 /usr/local/bin/python \ + && ln -sf /usr/bin/fdfind /usr/local/bin/fd \ + && python -m pip install --upgrade pip setuptools wheel \ + && python -m pip install --prefer-binary \ + pandas \ + numpy \ + openpyxl \ + xlsxwriter \ + xlrd \ + pillow \ + python-pptx \ + python-docx \ + pypdf \ + pdfplumber \ + pypdfium2 \ + pdf2image \ + img2pdf \ + sympy \ + mpmath \ + tqdm \ + python-dateutil \ + pytz \ + joblib \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* + +WORKDIR /workspace + +CMD ["sleep", "infinity"] diff --git a/agentscope-examples/harness-examples/harness-sandbox-docker/src/main/java/io/agentscope/examples/harness/sandbox/DockerPythonSandboxExample.java b/agentscope-examples/harness-examples/harness-sandbox-docker/src/main/java/io/agentscope/examples/harness/sandbox/DockerPythonSandboxExample.java new file mode 100644 index 000000000..f0a92db54 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-sandbox-docker/src/main/java/io/agentscope/examples/harness/sandbox/DockerPythonSandboxExample.java @@ -0,0 +1,174 @@ +/* + * 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.harness.sandbox; + +import static io.agentscope.examples.harness.common.util.ExampleUtils.ctx; +import static io.agentscope.examples.harness.common.util.ExampleUtils.getDashScopeApiKey; +import static io.agentscope.examples.harness.common.util.ExampleUtils.printWelcome; +import static io.agentscope.examples.harness.common.util.ExampleUtils.startChat; +import static io.agentscope.examples.harness.sandbox.DockerPythonSandboxImage.ensureImage; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.model.DashScopeChatModel; +import io.agentscope.core.model.Model; +import io.agentscope.harness.agent.HarnessAgent; +import io.agentscope.harness.agent.sandbox.SandboxDistributedOptions; +import io.agentscope.harness.agent.sandbox.filesystem.DockerFilesystemSpec; +import io.agentscope.harness.agent.sandbox.filesystem.SandboxFilesystemSpec; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; + +public final class DockerPythonSandboxExample { + + public static void main(String[] args) throws Exception { + printWelcome( + "AgentScope Harness Docker Python Sandbox", + "Interactive assistant that can execute Python scripts inside a Docker sandbox."); + + ensureImage(); + + Path workspace = Paths.get(".agentscope/python-sandbox-workspace"); + initWorkspaceIfAbsent(workspace); + + String apiKey = getDashScopeApiKey(); + Model model = + DashScopeChatModel.builder().apiKey(apiKey).modelName("qwen-max").stream(true) + .build(); + + SandboxFilesystemSpec sandboxSpec = + new DockerFilesystemSpec() + .image(DockerPythonSandboxImage.IMAGE) + .network("none") + .workspaceRoot("/workspace") + .workspaceProjectionRoots(List.of("AGENTS.md", "examples")); + HarnessAgent agent = + HarnessAgent.builder() + .name("docker-python-sandbox-agent") + .sysPrompt("你是一个 Python 数据分析助手。需要运行代码时,使用 shell 执行工具在 sandbox 中运行命令。") + .model(model) + .workspace(workspace) + .filesystem(sandboxSpec) + .sandboxDistributed( + SandboxDistributedOptions.builder() + .requireDistributed(false) + .build()) + .maxIters(20) + .build(); + + RuntimeContext ctx = ctx("docker-python-demo-session", "alice"); + System.out.println("Workspace: " + workspace.toAbsolutePath()); + System.out.println("Try: 请运行 examples/check_libs.py 检查 sandbox 里的 Python 环境。\n"); + startChat(agent, ctx); + } + + private static void initWorkspaceIfAbsent(Path workspace) throws Exception { + Files.createDirectories(workspace.resolve("examples")); + writeIfAbsent( + workspace.resolve("AGENTS.md"), + """ + # Docker Python Sandbox Assistant + + 你运行在一个 Docker sandbox 工作区中。 + + ## 可用能力 + - 使用 shell 执行工具运行 Python 代码和命令 + - 优先在 `/workspace` 下读写文件 + - 可以执行 `python examples/check_libs.py` 检查预装库 + - 可以执行 `python examples/analyze_sales.py` 运行示例数据分析脚本 + + ## 行为约定 + - 执行代码前先简要说明计划 + - 执行失败时读取错误输出并修复脚本 + - 不要声称已运行代码,除非确实调用了 shell 执行工具 + """); + writeIfAbsent( + workspace.resolve("examples/check_libs.py"), + """ + import importlib + import sqlite3 + import subprocess + import sys + + modules = [ + "pandas", + "numpy", + "openpyxl", + "xlsxwriter", + "xlrd", + "PIL", + "pptx", + "docx", + "pypdf", + "pdfplumber", + "pypdfium2", + "pdf2image", + "img2pdf", + "sympy", + "mpmath", + "tqdm", + "dateutil", + "pytz", + "joblib", + ] + + print(sys.version) + print("sqlite", sqlite3.sqlite_version) + missing = [] + for module in modules: + try: + importlib.import_module(module) + except Exception as exc: + missing.append((module, str(exc))) + for command in ["rg", "fd", "unzip", "unrar", "7z", "bc"]: + completed = subprocess.run(["sh", "-lc", f"command -v {command}"], capture_output=True, text=True) + if completed.returncode != 0: + missing.append((command, "command not found")) + if missing: + print("Missing dependencies:") + for name, reason in missing: + print(f"- {name}: {reason}") + raise SystemExit(1) + print("All expected Python modules and utility commands are available.") + """); + writeIfAbsent( + workspace.resolve("examples/analyze_sales.py"), + """ + import pandas as pd + + data = pd.DataFrame( + [ + {"region": "north", "product": "notebook", "revenue": 1200}, + {"region": "north", "product": "pen", "revenue": 300}, + {"region": "south", "product": "notebook", "revenue": 900}, + {"region": "south", "product": "pen", "revenue": 450}, + ] + ) + + summary = data.groupby("region", as_index=False)["revenue"].sum() + print(summary.to_string(index=False)) + """); + } + + private static void writeIfAbsent(Path path, String content) throws Exception { + if (Files.exists(path)) { + return; + } + Files.createDirectories(path.getParent()); + Files.writeString(path, content); + } +} diff --git a/agentscope-examples/harness-examples/harness-sandbox-docker/src/main/java/io/agentscope/examples/harness/sandbox/DockerPythonSandboxImage.java b/agentscope-examples/harness-examples/harness-sandbox-docker/src/main/java/io/agentscope/examples/harness/sandbox/DockerPythonSandboxImage.java new file mode 100644 index 000000000..57f78d4d4 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-sandbox-docker/src/main/java/io/agentscope/examples/harness/sandbox/DockerPythonSandboxImage.java @@ -0,0 +1,159 @@ +/* + * 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.harness.sandbox; + +import java.io.IOException; +import java.io.PrintStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.TimeUnit; + +final class DockerPythonSandboxImage { + + static final String IMAGE = "agentscope/python-sandbox:py311-slim"; + + private DockerPythonSandboxImage() {} + + static void ensureImage() throws Exception { + Path dockerfile = findDockerfile(); + CommandResult dockerInfo = runAllowFailure(dockerInfoCommand(), 30); + if (dockerInfo.exitCode() != 0) { + throw new IllegalStateException(dockerAvailabilityErrorMessage(dockerInfo.output())); + } + if (runAllowFailure(inspectCommand(IMAGE), 30).exitCode() == 0) { + System.out.println("✓ Docker image exists: " + IMAGE); + return; + } + System.out.println("Docker image not found. Building: " + IMAGE); + runStreaming( + buildCommand(IMAGE, dockerfile), + 1800, + "Failed to build Docker image: " + IMAGE, + System.out, + System.err); + } + + static List dockerInfoCommand() { + return List.of("docker", "info"); + } + + static List inspectCommand(String image) { + return List.of("docker", "image", "inspect", image); + } + + static List buildCommand(String image, Path dockerfile) { + return List.of( + "docker", + "build", + "-t", + image, + "-f", + dockerfile.toString(), + dockerfile.getParent().toString()); + } + + static String dockerAvailabilityErrorMessage(String output) { + String details = output != null ? output.strip() : ""; + if (details.contains("Cannot connect to the Docker daemon")) { + return "Docker CLI is available, but the Docker daemon is not reachable." + + " Please start Docker Desktop or verify the active Docker context/socket.\n" + + details; + } + return "Docker is not available or not usable. Please install Docker and ensure the" + + " Docker daemon is running.\n" + + details; + } + + private static Path findDockerfile() { + String relative = "src/main/docker/python-sandbox/Dockerfile"; + String moduleRelative = + "agentscope-examples/harness-examples/harness-sandbox-docker/" + relative; + Path cwd = Path.of(System.getProperty("user.dir")); + for (Path candidate : List.of(cwd.resolve(relative), cwd.resolve(moduleRelative))) { + if (Files.isRegularFile(candidate)) { + return candidate.toAbsolutePath().normalize(); + } + } + throw new IllegalStateException( + "Cannot find Dockerfile. Run from repository root or harness-sandbox module."); + } + + private static void run(List command, int timeoutSeconds, String errorMessage) + throws Exception { + CommandResult result = runAllowFailure(command, timeoutSeconds); + if (result.exitCode() != 0) { + throw new IllegalStateException(errorMessage + "\n" + result.output()); + } + } + + static void runStreaming( + List command, + int timeoutSeconds, + String errorMessage, + PrintStream stdout, + PrintStream stderr) + throws Exception { + Process process = new ProcessBuilder(command).start(); + Thread stdoutForwarder = pipe(process.getInputStream(), stdout, "docker-build-stdout"); + Thread stderrForwarder = pipe(process.getErrorStream(), stderr, "docker-build-stderr"); + boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + stdoutForwarder.join(); + stderrForwarder.join(); + stderr.println("Command timed out: " + String.join(" ", command)); + throw new IllegalStateException(errorMessage); + } + stdoutForwarder.join(); + stderrForwarder.join(); + if (process.exitValue() != 0) { + throw new IllegalStateException(errorMessage); + } + } + + private static Thread pipe(java.io.InputStream source, PrintStream target, String name) { + Thread thread = + new Thread( + () -> { + try (source) { + source.transferTo(target); + target.flush(); + } catch (IOException ex) { + throw new UncheckedIOException(ex); + } + }, + name); + thread.start(); + return thread; + } + + private static CommandResult runAllowFailure(List command, int timeoutSeconds) + throws IOException, InterruptedException { + Process process = new ProcessBuilder(command).redirectErrorStream(true).start(); + boolean finished = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + return new CommandResult(124, "Command timed out: " + String.join(" ", command)); + } + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + return new CommandResult(process.exitValue(), output); + } + + private record CommandResult(int exitCode, String output) {} +} diff --git a/agentscope-examples/harness-examples/harness-sandbox-docker/src/test/java/io/agentscope/examples/harness/sandbox/DockerPythonSandboxImageTest.java b/agentscope-examples/harness-examples/harness-sandbox-docker/src/test/java/io/agentscope/examples/harness/sandbox/DockerPythonSandboxImageTest.java new file mode 100644 index 000000000..8b7caee91 --- /dev/null +++ b/agentscope-examples/harness-examples/harness-sandbox-docker/src/test/java/io/agentscope/examples/harness/sandbox/DockerPythonSandboxImageTest.java @@ -0,0 +1,45 @@ +/* + * 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.harness.sandbox; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.ByteArrayOutputStream; +import java.io.PrintStream; +import java.nio.charset.StandardCharsets; +import java.util.List; +import org.junit.jupiter.api.Test; + +class DockerPythonSandboxImageTest { + + @Test + void runStreamingPrintsCommandOutput() throws Exception { + ByteArrayOutputStream stdoutBuffer = new ByteArrayOutputStream(); + ByteArrayOutputStream stderrBuffer = new ByteArrayOutputStream(); + try (PrintStream stdout = new PrintStream(stdoutBuffer, true, StandardCharsets.UTF_8); + PrintStream stderr = new PrintStream(stderrBuffer, true, StandardCharsets.UTF_8)) { + DockerPythonSandboxImage.runStreaming( + List.of("sh", "-c", "printf 'build-log\\n'; printf 'warn-log\\n' >&2"), + 5, + "failed", + stdout, + stderr); + } + + assertTrue(stdoutBuffer.toString(StandardCharsets.UTF_8).contains("build-log")); + assertTrue(stderrBuffer.toString(StandardCharsets.UTF_8).contains("warn-log")); + } +} diff --git a/agentscope-examples/harness-examples/pom.xml b/agentscope-examples/harness-examples/pom.xml new file mode 100644 index 000000000..0580c4cbc --- /dev/null +++ b/agentscope-examples/harness-examples/pom.xml @@ -0,0 +1,46 @@ + + + + 4.0.0 + + io.agentscope + agentscope-examples + ${revision} + ../pom.xml + + + harness-examples + pom + + harness-examples + http://maven.apache.org + + + 2.24.3 + UTF-8 + + + + harness-example-common + harness-quickstart + harness-sandbox-docker + harness-example-local + harness-example-sandbox + harness-example-remote + + diff --git a/agentscope-examples/pom.xml b/agentscope-examples/pom.xml index dcb649dae..699aad105 100644 --- a/agentscope-examples/pom.xml +++ b/agentscope-examples/pom.xml @@ -55,6 +55,7 @@ multiagent-patterns/pipeline multiagent-patterns/skills a2a-rocketmq + harness-examples diff --git a/agentscope-harness/pom.xml b/agentscope-harness/pom.xml new file mode 100644 index 000000000..2fad2bb18 --- /dev/null +++ b/agentscope-harness/pom.xml @@ -0,0 +1,65 @@ + + + + + 4.0.0 + + io.agentscope + agentscope-parent + ${revision} + ../pom.xml + + + AgentScope Java - Harness + agentscope-harness + AgentScope Harness - Enhanced agent runtime with workspace context, subagent orchestration, memory management, and backend abstraction + + + + io.agentscope + agentscope-core + + + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + + + org.apache.commons + commons-compress + 1.27.1 + + + + + redis.clients + jedis + + + + + com.aliyun.oss + aliyun-sdk-oss + 3.18.5 + + + 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 new file mode 100644 index 000000000..189f1bade --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java @@ -0,0 +1,1377 @@ +/* + * 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; + +import com.fasterxml.jackson.databind.JsonNode; +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.Event; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.agent.StreamOptions; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.memory.InMemoryMemory; +import io.agentscope.core.memory.Memory; +import io.agentscope.core.message.Msg; +import io.agentscope.core.model.ExecutionConfig; +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelRegistry; +import io.agentscope.core.session.JsonSession; +import io.agentscope.core.session.Session; +import io.agentscope.core.skill.AgentSkill; +import io.agentscope.core.skill.SkillBox; +import io.agentscope.core.skill.repository.AgentSkillRepository; +import io.agentscope.core.skill.repository.FileSystemSkillRepository; +import io.agentscope.core.state.SessionKey; +import io.agentscope.core.state.SimpleSessionKey; +import io.agentscope.core.state.StateModule; +import io.agentscope.core.tool.ToolExecutionContext; +import io.agentscope.core.tool.Toolkit; +import io.agentscope.harness.agent.filesystem.AbstractFilesystem; +import io.agentscope.harness.agent.filesystem.AbstractSandboxFilesystem; +import io.agentscope.harness.agent.filesystem.LocalFilesystemSpec; +import io.agentscope.harness.agent.filesystem.LocalFilesystemWithShell; +import io.agentscope.harness.agent.filesystem.RemoteFilesystemSpec; +import io.agentscope.harness.agent.hook.AgentTraceHook; +import io.agentscope.harness.agent.hook.CompactionHook; +import io.agentscope.harness.agent.hook.MemoryFlushHook; +import io.agentscope.harness.agent.hook.MemoryMaintenanceHook; +import io.agentscope.harness.agent.hook.SandboxLifecycleHook; +import io.agentscope.harness.agent.hook.SessionPersistenceHook; +import io.agentscope.harness.agent.hook.SubagentsHook; +import io.agentscope.harness.agent.hook.SubagentsHook.SubagentEntry; +import io.agentscope.harness.agent.hook.ToolResultEvictionHook; +import io.agentscope.harness.agent.hook.WorkspaceContextHook; +import io.agentscope.harness.agent.memory.MemoryConsolidator; +import io.agentscope.harness.agent.memory.MemoryFlushManager; +import io.agentscope.harness.agent.memory.compaction.CompactionConfig; +import io.agentscope.harness.agent.memory.compaction.ConversationCompactor; +import io.agentscope.harness.agent.memory.compaction.ToolResultEvictionConfig; +import io.agentscope.harness.agent.sandbox.SandboxBackedFilesystem; +import io.agentscope.harness.agent.sandbox.SandboxContext; +import io.agentscope.harness.agent.sandbox.SandboxDistributedOptions; +import io.agentscope.harness.agent.sandbox.SandboxManager; +import io.agentscope.harness.agent.sandbox.SandboxStateStore; +import io.agentscope.harness.agent.sandbox.SessionSandboxStateStore; +import io.agentscope.harness.agent.sandbox.filesystem.SandboxFilesystemSpec; +import io.agentscope.harness.agent.sandbox.snapshot.NoopSnapshotSpec; +import io.agentscope.harness.agent.session.WorkspaceSession; +import io.agentscope.harness.agent.store.NamespaceFactory; +import io.agentscope.harness.agent.subagent.AgentSpecLoader; +import io.agentscope.harness.agent.subagent.SubagentFactory; +import io.agentscope.harness.agent.subagent.SubagentSpec; +import io.agentscope.harness.agent.subagent.task.DefaultTaskRepository; +import io.agentscope.harness.agent.subagent.task.TaskRepository; +import io.agentscope.harness.agent.tool.FilesystemTool; +import io.agentscope.harness.agent.tool.MemoryGetTool; +import io.agentscope.harness.agent.tool.MemorySearchTool; +import io.agentscope.harness.agent.tool.SessionSearchTool; +import io.agentscope.harness.agent.tool.ShellExecuteTool; +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +/** + * HarnessAgent is the user-facing API that wraps {@link ReActAgent} with enhanced harness practices: + * + *

      + *
    • Workspace-based context loading (AGENTS.md, KNOWLEDGE.md) + *
    • Skill loading via optional {@link AgentSkillRepository}, else {@link FileSystemSkillRepository} on + * workspace/skills/ + *
    • Subagent orchestration via task/task_output tools (sync + background) + *
    • Memory flush and message offload before context compression + *
    • Session environment initialization (OS, date, workspace info) + *
    • Pluggable file-system backend (local, sandbox, composite) + *
    • Memory search/get tools + *
    + * + *

    Usage: + * + *

    {@code
    + * HarnessAgent agent = HarnessAgent.builder()
    + *     .name("MyAgent")
    + *     .model(model) // or .model("openai:gpt-5.5") via {@link ModelRegistry}
    + *     .sysPrompt("You are a helpful assistant.")
    + *     .workspace("/path/to/workspace")
    + *     .build();
    + *
    + * Msg response = agent.call(
    + *     Msg.userMsg("Hello!"),
    + *     RuntimeContext.builder().sessionId("sess-1").build()
    + * ).block();
    + * }
    + */ +public class HarnessAgent implements Agent, StateModule { + + private static final Logger log = LoggerFactory.getLogger(HarnessAgent.class); + + private final ReActAgent delegate; + private final WorkspaceManager workspaceManager; + private final CompactionHook compactionHook; + private final AtomicReference userIdRef; + private final AtomicReference sessionIdRef; + private final Session defaultSession; + private final SandboxContext defaultSandboxContext; + private RuntimeContext runtimeContext; + + private HarnessAgent( + ReActAgent delegate, + WorkspaceManager workspaceManager, + CompactionHook compactionHook, + AtomicReference userIdRef, + AtomicReference sessionIdRef, + Session defaultSession, + SandboxContext defaultSandboxContext) { + this.delegate = delegate; + this.workspaceManager = workspaceManager; + this.compactionHook = compactionHook; + this.userIdRef = userIdRef; + this.sessionIdRef = sessionIdRef; + this.defaultSession = defaultSession; + this.defaultSandboxContext = defaultSandboxContext; + } + + /** Calls the agent with a runtime context, which provides sessionId and other metadata. */ + public Mono call(Msg msg, RuntimeContext ctx) { + return call(List.of(msg), ctx); + } + + /** Calls the agent with multiple messages and a runtime context. */ + public Mono call(List msgs, RuntimeContext ctx) { + bindRuntimeContext(ctx); + return delegate.call(msgs, coreForDelegate()) + .onErrorResume( + e -> { + if (isContextOverflowError(e)) { + return recoverFromOverflow(msgs); + } + return Mono.error(e); + }); + } + + /** Streams the agent response with a runtime context. */ + public Flux stream(List msgs, StreamOptions options, RuntimeContext ctx) { + bindRuntimeContext(ctx); + return delegate.stream(msgs, options, coreForDelegate()); + } + + /** Streams with default {@link StreamOptions} and a runtime context. */ + public Flux stream(List msgs, RuntimeContext ctx) { + return stream(msgs, StreamOptions.defaults(), ctx); + } + + /** Streams a single message with default {@link StreamOptions} and a runtime context. */ + public Flux stream(Msg msg, RuntimeContext ctx) { + return stream(List.of(msg), ctx); + } + + private RuntimeContext coreForDelegate() { + return runtimeContext != null ? runtimeContext : RuntimeContext.empty(); + } + + private Mono recoverFromOverflow(List msgs) { + if (compactionHook != null) { + // Force a compaction of the current memory contents by lowering the trigger threshold + // to 1 so that compactIfNeeded always fires. + log.warn( + "Context overflow detected, triggering emergency compaction via" + + " CompactionHook"); + return forceCompactAndRetry(delegate.getMemory(), msgs); + } + return Mono.error( + new RuntimeException( + "Context overflow: no compaction configured, unable to recover")); + } + + private Mono forceCompactAndRetry(Memory memory, List msgs) { + List allMsgs = memory.getMessages(); + if (allMsgs.isEmpty()) { + return Mono.error( + new RuntimeException("Context overflow: memory is empty, cannot compact")); + } + RuntimeContext ctx = this.runtimeContext; + String agentId = delegate.getName(); + String sessionId = + ctx != null && ctx.getSessionId() != null ? ctx.getSessionId() : "default"; + + // Force trigger by using a config with threshold=1 (always compact) + CompactionConfig forceConfig = CompactionConfig.builder().triggerMessages(1).build(); + MemoryFlushManager fm = new MemoryFlushManager(workspaceManager, delegate.getModel()); + ConversationCompactor compactor = new ConversationCompactor(delegate.getModel(), fm); + + return compactor + .compactIfNeeded(allMsgs, forceConfig, agentId, sessionId) + .flatMap( + opt -> { + if (opt.isPresent()) { + memory.clear(); + for (Msg m : opt.get()) { + memory.addMessage(m); + } + return delegate.call(msgs, coreRuntimeForRecovery()); + } + return Mono.error( + new RuntimeException( + "Context overflow: emergency compaction yielded no" + + " result")); + }); + } + + private io.agentscope.core.agent.RuntimeContext coreRuntimeForRecovery() { + return runtimeContext != null + ? runtimeContext + : io.agentscope.core.agent.RuntimeContext.empty(); + } + + private static boolean isContextOverflowError(Throwable e) { + String message = e.getMessage(); + if (message == null) { + return false; + } + String lower = message.toLowerCase(); + return lower.contains("context_length_exceeded") + || lower.contains("context length") + || lower.contains("maximum context") + || lower.contains("token limit") + || lower.contains("too many tokens") + || lower.contains("exceeds the model's maximum") + || lower.contains("reduce the length"); + } + + private void bindRuntimeContext(RuntimeContext ctx) { + if (ctx == null) { + this.runtimeContext = null; + return; + } + RuntimeContext effective = ensureSessionDefaults(ctx); + this.runtimeContext = effective; + if (userIdRef != null) { + userIdRef.set(effective.getUserId()); + } + if (sessionIdRef != null) { + String sid = + effective.getSessionKey() != null + ? effective.getSessionKey().toIdentifier() + : effective.getSessionId(); + sessionIdRef.set(sid); + } + if (effective.getSession() != null && effective.getSessionKey() != null) { + try { + delegate.loadIfExists(effective.getSession(), effective.getSessionKey()); + } catch (Exception e) { + log.warn("Failed to load session state: {}", e.getMessage()); + } + } + } + + /** + * Fills in default Session and SessionKey when the caller didn't provide them. + * Session defaults to the agent-level {@link #defaultSession} (JsonSession). + * SessionKey defaults to {@code SimpleSessionKey.of(sessionId)} when sessionId is + * available, or {@code SimpleSessionKey.of(agentName)} as a last resort. + */ + private RuntimeContext ensureSessionDefaults(RuntimeContext ctx) { + Session session = ctx.getSession() != null ? ctx.getSession() : defaultSession; + SessionKey sessionKey = ctx.getSessionKey(); + if (sessionKey == null) { + String id = ctx.getSessionId(); + if (id != null && !id.isBlank()) { + sessionKey = SimpleSessionKey.of(id); + } else { + sessionKey = SimpleSessionKey.of(delegate.getName()); + } + } + // Inject default sandbox context if the call doesn't provide one + SandboxContext sandboxCtx = + ctx.get(SandboxContext.class) != null + ? ctx.get(SandboxContext.class) + : defaultSandboxContext; + + if (session == ctx.getSession() + && sessionKey == ctx.getSessionKey() + && sandboxCtx == ctx.get(SandboxContext.class)) { + return ctx; + } + return RuntimeContext.builder() + .sessionId(ctx.getSessionId()) + .userId(ctx.getUserId()) + .session(session) + .sessionKey(sessionKey) + .putAll(ctx.getExtra()) + .put(SandboxContext.class, sandboxCtx) + .build(); + } + + // ==================== Agent interface delegation ==================== + + @Override + public Mono call(List msgs) { + return delegate.call(msgs); + } + + @Override + public Mono call(List msgs, Class structuredModel) { + return delegate.call(msgs, structuredModel); + } + + @Override + public Mono call(List msgs, JsonNode schema) { + return delegate.call(msgs, schema); + } + + @Override + public Flux stream(List msgs, StreamOptions options) { + return delegate.stream(msgs, options); + } + + @Override + public Flux stream(List msgs, StreamOptions options, Class structuredModel) { + return delegate.stream(msgs, options, structuredModel); + } + + @Override + public Flux stream(List msgs, StreamOptions options, JsonNode schema) { + return delegate.stream(msgs, options, schema); + } + + @Override + public Mono observe(Msg msg) { + return delegate.observe(msg); + } + + @Override + public Mono observe(List msgs) { + return delegate.observe(msgs); + } + + @Override + public void interrupt() { + delegate.interrupt(); + } + + @Override + public void interrupt(Msg msg) { + delegate.interrupt(msg); + } + + @Override + public String getName() { + return delegate.getName(); + } + + @Override + public String getAgentId() { + return delegate.getAgentId(); + } + + @Override + public String getDescription() { + return delegate.getDescription(); + } + + public ReActAgent getDelegate() { + return delegate; + } + + public WorkspaceManager getWorkspaceManager() { + return workspaceManager; + } + + public RuntimeContext getRuntimeContext() { + return runtimeContext; + } + + // ==================== StateModule delegation ==================== + + @Override + public void saveTo(Session session, SessionKey sessionKey) { + delegate.saveTo(session, sessionKey); + } + + @Override + public void loadFrom(Session session, SessionKey sessionKey) { + delegate.loadFrom(session, sessionKey); + } + + @Override + public boolean loadIfExists(Session session, SessionKey sessionKey) { + return delegate.loadIfExists(session, sessionKey); + } + + // ==================== Builder ==================== + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + // Core ReActAgent params + private String name; + private String description; + private String sysPrompt; + private Model model; + private Toolkit toolkit = new Toolkit(); + private int maxIters = 15; + private ExecutionConfig modelExecutionConfig; + private ExecutionConfig toolExecutionConfig; + private GenerateOptions generateOptions; + private final List hooks = new ArrayList<>(); + + /** When {@code null}, skills load from {@code workspace/skills/} via {@link FileSystemSkillRepository}. */ + private AgentSkillRepository skillRepository; + + private ToolExecutionContext toolExecutionContext; + + // Harness-specific params + private Path workspace; + private String environmentMemory; + private AbstractFilesystem abstractFilesystem; + private Session session; + private SandboxDistributedOptions sandboxDistributedOptions; + + /** + * When {@code true}, this agent is a leaf worker (spawned subagent): it does not register + * {@link SubagentsHook}, preventing recursive delegation. Main agents keep this {@code + * false}. + */ + private boolean leafSubagent = false; + + /** + * When {@code true} (default), registers {@link AgentTraceHook} to log reasoning and tool + * execution at INFO; set logger {@code io.agentscope.harness.agent.hook.AgentTraceHook} to + * DEBUG for full args and results. When {@code false}, no trace hook is added. + */ + private boolean agentTracingLogEnabled = true; + + /** + * When non-null, enables {@link CompactionHook} with this configuration. + * Set via {@link #compaction(CompactionConfig)}. + */ + private CompactionConfig compactionConfig = null; + + /** + * When non-null, enables {@link ToolResultEvictionHook} with this configuration. + * Set via {@link #toolResultEviction(ToolResultEvictionConfig)}. + */ + private ToolResultEvictionConfig toolResultEvictionConfig = null; + + private final List subagentSpecs = new ArrayList<>(); + private final List customSubagentFactories = new ArrayList<>(); + private TaskRepository taskRepository; + private Object externalSubagentTool; + private Function modelResolver; + private final List additionalContextFiles = new ArrayList<>(); + private int maxContextTokens = 8000; + private boolean useLegacyXmlWorkspaceContext = false; + + // Filesystem mode configuration (at most one of these three is set) + private SandboxFilesystemSpec sandboxFilesystemSpec; + private RemoteFilesystemSpec remoteFilesystemSpec; + private LocalFilesystemSpec localFilesystemSpec; + + public Builder name(String name) { + this.name = name; + return this; + } + + public Builder description(String description) { + this.description = description; + return this; + } + + public Builder sysPrompt(String sysPrompt) { + this.sysPrompt = sysPrompt; + return this; + } + + public Builder model(Model model) { + this.model = model; + return this; + } + + /** + * Configures the model from a string id resolved via {@link ModelRegistry}: a named + * registration ({@link ModelRegistry#register(String, Model)}) or a built-in pattern such + * as {@code openai:gpt-5.5}, {@code dashscope:qwen-max}, {@code anthropic:claude-sonnet-4-5}, + * {@code gemini:gemini-2.0-flash}, or {@code ollama:llama3}. API keys for auto-created models + * come from standard environment variables ({@code OPENAI_API_KEY}, {@code DASHSCOPE_API_KEY}, + * etc.). + * + * @param modelId registry id or {@code provider:model} string + * @return this builder + * @throws IllegalArgumentException if the id cannot be resolved + */ + public Builder model(String modelId) { + this.model = ModelRegistry.resolve(modelId); + return this; + } + + public Builder toolkit(Toolkit toolkit) { + this.toolkit = toolkit; + return this; + } + + public Builder maxIters(int maxIters) { + this.maxIters = maxIters; + return this; + } + + public Builder modelExecutionConfig(ExecutionConfig config) { + this.modelExecutionConfig = config; + return this; + } + + public Builder toolExecutionConfig(ExecutionConfig config) { + this.toolExecutionConfig = config; + return this; + } + + public Builder generateOptions(GenerateOptions options) { + this.generateOptions = options; + return this; + } + + public Builder hook(Hook hook) { + this.hooks.add(hook); + return this; + } + + public Builder hooks(List hooks) { + this.hooks.addAll(hooks); + return this; + } + + /** + * Supplies skills from a custom repository (e.g. {@code GitSkillRepository}). A {@link SkillBox} is + * assembled automatically from this repository and the agent toolkit. When {@code null} (default), + * skills are loaded from {@code <workspace>/skills/} using {@link FileSystemSkillRepository} when + * that directory exists. + */ + public Builder skillRepository(AgentSkillRepository skillRepository) { + this.skillRepository = skillRepository; + return this; + } + + public Builder toolExecutionContext(ToolExecutionContext ctx) { + this.toolExecutionContext = ctx; + return this; + } + + /** + * Sets the workspace directory. Pass {@code null} to use the default + * {@code ${cwd}/.agentscope/workspace}. + * + * @see #workspace(String) + */ + public Builder workspace(Path workspace) { + this.workspace = workspace; + return this; + } + + /** + * Sets the workspace directory from a filesystem path string (resolved with + * {@link Path#of(String, String...)}). Equivalent to {@link #workspace(Path)} with + * {@code Path.of(path.strip())}. + * + *

    Pass {@code null} for the same default as {@link #workspace(Path)} with a {@code null} + * argument. Blank or whitespace-only strings are rejected. + * + * @param path absolute or relative path string, or {@code null} for the default workspace + */ + public Builder workspace(String path) { + if (path == null) { + this.workspace = null; + return this; + } + String trimmed = path.strip(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("workspace path must not be blank"); + } + this.workspace = Path.of(trimmed); + return this; + } + + public Builder environmentMemory(String environmentMemory) { + this.environmentMemory = environmentMemory; + return this; + } + + /** + * Escape hatch: sets a custom {@link AbstractFilesystem} implementation directly. + * + *

    Prefer {@link #filesystem(LocalFilesystemSpec)}, {@link #filesystem(RemoteFilesystemSpec)} + * or {@link #filesystem(SandboxFilesystemSpec)} unless you have a bespoke backend that is + * not expressible via any of the declarative specs. + */ + public Builder abstractFilesystem(AbstractFilesystem backend) { + this.abstractFilesystem = backend; + return this; + } + + /** + * Configures Mode 2 — sandbox filesystem mode: fully isolated workspace running in a + * sandbox (for example Docker). Long-term memory extraction/read and shell execution are + * all routed through the sandbox session. State can be persisted via snapshots and resumed + * by the configured isolation scope. + * + * @param spec sandbox filesystem spec (for example Docker sandbox spec) + * @return this builder + */ + public Builder filesystem(SandboxFilesystemSpec spec) { + this.sandboxFilesystemSpec = spec; + return this; + } + + /** + * Configures Mode 1 — composite (non-sandbox) filesystem mode: a unified workspace + * view that blends a local {@code LocalFilesystem} backend with a shared + * {@code RemoteFilesystem} for distributed long-term memory. Shell execution is not + * available in this mode — selected prefixes ({@code MEMORY.md}, {@code memory/}, + * {@code agents/.../sessions/}) are routed to the store to keep memory consistent across + * replicas. + */ + public Builder filesystem(RemoteFilesystemSpec spec) { + this.remoteFilesystemSpec = spec; + return this; + } + + /** + * Configures Mode 3 — local filesystem with shell mode: the agent workspace is a + * plain local directory and shell commands execute on the host. Long-term memory is kept + * on the same local disk. Use for single-process / single-replica deployments. + */ + public Builder filesystem(LocalFilesystemSpec spec) { + this.localFilesystemSpec = spec; + return this; + } + + /** + * Enables or disables agent execution trace logging via {@link AgentTraceHook}. + * Default is {@code true}. + */ + public Builder enableAgentTracingLog(boolean enabled) { + this.agentTracingLogEnabled = enabled; + return this; + } + + /** + * Enables the {@link CompactionHook} with the given configuration as the conversation + * compaction strategy. + * + *

    Use {@link CompactionConfig#builder()} to configure trigger thresholds, the keep + * policy, and whether to flush/offload before summarisation. + */ + public Builder compaction(CompactionConfig config) { + this.compactionConfig = config; + return this; + } + + /** + * Enables {@link ToolResultEvictionHook} with the given configuration. + * + *

    When active, any tool result whose text content exceeds + * {@link ToolResultEvictionConfig#getMaxResultChars()} is written to the + * {@link AbstractFilesystem} and replaced with a compact placeholder in-context. + * Use {@link ToolResultEvictionConfig#defaults()} for sensible out-of-the-box settings. + * + *

    This mechanism is independent of conversation compaction: eviction addresses + * individual oversized results (context width), while compaction addresses accumulated + * conversation length (context depth). + */ + public Builder toolResultEviction(ToolResultEvictionConfig config) { + this.toolResultEvictionConfig = config; + return this; + } + + /** + * Sets the default {@link Session} used for state persistence when + * {@link RuntimeContext} does not provide one. When not set, defaults to a + * {@link JsonSession} stored under {@code /../sessions/}. + */ + public Builder session(Session session) { + this.session = session; + return this; + } + + /** + * Enables high-level distributed sandbox configuration. + * + *

    Bundles distributed concerns that pair with {@link #filesystem(SandboxFilesystemSpec)}: + * + *

      + *
    • distributed {@link Session} for sandbox state slots + *
    • optional {@link io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec} + * override for workspace archive persistence + *
    • {@code requireDistributed} gate for fail-fast validation + *
    + * + *

    Configure {@link IsolationScope} on the {@code SandboxFilesystemSpec} only. + * + *

    Requires sandbox mode (i.e. {@link #filesystem(SandboxFilesystemSpec)}). + */ + public Builder sandboxDistributed(SandboxDistributedOptions options) { + this.sandboxDistributedOptions = options; + return this; + } + + /** Adds a subagent spec (programmatic; workspace specs come from {@code subagents/*.md}). */ + public Builder subagent(SubagentSpec spec) { + this.subagentSpecs.add(spec); + return this; + } + + public Builder subagents(List specs) { + this.subagentSpecs.addAll(specs); + return this; + } + + /** Adds a fully custom subagent factory for a given agent id. */ + public Builder subagentFactory(String name, Function factory) { + this.customSubagentFactories.add(new SubagentFactoryEntry(name, factory)); + return this; + } + + /** Sets a custom TaskRepository for background subagent execution. */ + public Builder taskRepository(TaskRepository taskRepository) { + this.taskRepository = taskRepository; + return this; + } + + /** + * Adds a custom context file (relative to workspace) that will be loaded into + * the system prompt alongside AGENTS.md, MEMORY.md, and KNOWLEDGE.md. + * Useful for files like SOUL.md, PREFERENCE.md, etc. + * + * @param relativePath workspace-relative path (e.g., "SOUL.md") + * @return this builder instance + */ + public Builder additionalContextFile(String relativePath) { + if (relativePath != null && !relativePath.isBlank()) { + this.additionalContextFiles.add(relativePath); + } + return this; + } + + /** + * Sets the maximum token budget for workspace context injected into the system prompt. + * + * @param maxTokens maximum tokens (default: 8000) + * @return this builder instance + */ + public Builder maxContextTokens(int maxTokens) { + this.maxContextTokens = maxTokens; + return this; + } + + /** + * Injects an external subagent tool (typically {@code SessionsTool}) to replace the + * default {@code AgentTool}. Used by {@code AgentBootstrap} for session-mode orchestration. + */ + public Builder externalSubagentTool(Object tool) { + this.externalSubagentTool = tool; + return this; + } + + /** + * Sets a resolver for model name strings to {@link Model} instances. Used when spec-based + * subagents specify a {@code model} override (e.g. {@code "openai:gpt-4o-mini"}). When unset, + * {@link ModelRegistry#resolve(String)} is used so subagent specs can use the same string ids + * as {@link #model(String)}. + */ + public Builder modelResolver(Function resolver) { + this.modelResolver = resolver; + return this; + } + + /** + * Switches workspace context rendering between markdown (default) and legacy XML + * {@code } style. + */ + public Builder useLegacyXmlWorkspaceContext(boolean enabled) { + this.useLegacyXmlWorkspaceContext = enabled; + return this; + } + + public List buildSubagentEntries(Path resolvedWorkspace) { + return buildSubagentEntries(resolvedWorkspace, null); + } + + /** + * Builds the subagent entries from programmatic specs, {@code workspace/subagents/*.md}, + * and custom factories. Useful for callers (e.g. {@code AgentBootstrap}) that need to + * extract agent factories before building the full agent. + */ + public List buildSubagentEntries( + Path resolvedWorkspace, SandboxBackedFilesystem sandboxFs) { + List allSpecs = new ArrayList<>(subagentSpecs); + + Path subagentsDir = resolvedWorkspace.resolve("subagents"); + if (Files.isDirectory(subagentsDir)) { + allSpecs.addAll(AgentSpecLoader.loadFromDirectory(subagentsDir)); + } + + List entries = new ArrayList<>(); + + entries.add( + new SubagentEntry( + "general-purpose", + "General-purpose subagent with same capabilities as the main agent." + + " Use for any isolated task that can be fully delegated.", + buildGeneralPurposeFactory(resolvedWorkspace, sandboxFs))); + + for (SubagentSpec spec : allSpecs) { + if (spec.getName() != null) { + entries.add( + new SubagentEntry( + spec.getName(), + spec.getDescription() != null + ? spec.getDescription() + : spec.getName(), + buildSpecFactory(spec, resolvedWorkspace))); + } + } + + for (SubagentFactoryEntry custom : customSubagentFactories) { + entries.add( + new SubagentEntry( + custom.name(), + custom.name(), + () -> custom.factory().apply(custom.name()))); + } + + return entries; + } + + public HarnessAgent build() { + int specCount = 0; + if (sandboxFilesystemSpec != null) specCount++; + if (remoteFilesystemSpec != null) specCount++; + if (localFilesystemSpec != null) specCount++; + if (specCount > 1) { + throw new IllegalStateException( + "At most one of sandboxFilesystemSpec, remoteFilesystemSpec," + + " localFilesystemSpec may be configured"); + } + if (abstractFilesystem != null && specCount > 0) { + throw new IllegalStateException( + "abstractFilesystem() is an escape hatch and is mutually exclusive with" + + " filesystem(...) specs"); + } + if (sandboxDistributedOptions != null && sandboxFilesystemSpec == null) { + throw new IllegalStateException( + "sandboxDistributed(...) requires sandbox mode." + + " Configure filesystem(SandboxFilesystemSpec) first."); + } + Path resolvedWorkspace = + workspace != null + ? workspace + : Paths.get(System.getProperty("user.dir")) + .resolve(".agentscope/workspace"); + String resolvedAgentId = name != null ? name : "HarnessAgent"; + Session effectiveSession = + sandboxDistributedOptions != null + && sandboxDistributedOptions.getSession() != null + ? sandboxDistributedOptions.getSession() + : session; + if (effectiveSession == null) { + effectiveSession = new WorkspaceSession(resolvedWorkspace, resolvedAgentId); + } + + // Mode 1 (RemoteFilesystemSpec) is inherently distributed: automatically require a + // distributed Session so that conversation state is also shared across replicas. + if (remoteFilesystemSpec != null && effectiveSession instanceof WorkspaceSession) { + throw new IllegalStateException( + "filesystem(RemoteFilesystemSpec) is designed for distributed /" + + " multi-replica deployments, but the effective Session is a local" + + " WorkspaceSession. Configure a distributed Session backend (for" + + " example RedisSession) via .session(...)."); + } + + AtomicReference userIdRef = new AtomicReference<>(); + AtomicReference sessionIdRef = new AtomicReference<>(); + AbstractFilesystem filesystem = + resolveFilesystem(resolvedWorkspace, resolvedAgentId, userIdRef, sessionIdRef); + + // ---- Sandbox integration ---- + SandboxLifecycleHook sandboxLifecycleHook = null; + SandboxContext defaultSandboxContext = null; + SandboxBackedFilesystem capturedSandboxFs = null; + if (sandboxFilesystemSpec != null) { + if (sandboxDistributedOptions != null + && sandboxDistributedOptions.getSnapshotSpec() != null) { + sandboxFilesystemSpec.snapshotSpec(sandboxDistributedOptions.getSnapshotSpec()); + } + capturedSandboxFs = new SandboxBackedFilesystem(); + capturedSandboxFs.configureNamespace(buildDynamicNamespaceFactory(userIdRef)); + filesystem = capturedSandboxFs; + + defaultSandboxContext = sandboxFilesystemSpec.toSandboxContext(resolvedWorkspace); + // Mode 2 (SandboxFilesystemSpec) always validates distributed prerequisites unless + // the caller explicitly opts out via sandboxDistributed(requireDistributed=false). + boolean skipDistributedValidation = + sandboxDistributedOptions != null + && !sandboxDistributedOptions.isRequireDistributed(); + if (!skipDistributedValidation) { + validateDistributedSandboxConfig(effectiveSession, defaultSandboxContext); + } + + SandboxStateStore stateStore = + sandboxFilesystemSpec.getSandboxStateStore() != null + ? sandboxFilesystemSpec.getSandboxStateStore() + : new SessionSandboxStateStore(effectiveSession, resolvedAgentId); + SandboxManager sandboxManager = + new SandboxManager( + defaultSandboxContext.getClient(), stateStore, resolvedAgentId); + sandboxLifecycleHook = new SandboxLifecycleHook(sandboxManager, capturedSandboxFs); + } + WorkspaceManager wsManager = new WorkspaceManager(resolvedWorkspace, filesystem); + wsManager.validate(); + + Memory memory = new InMemoryMemory(); + + // ---- Hooks ---- + List allHooks = new ArrayList<>(hooks); + + // Sandbox lifecycle hook runs first (priority=50) — acquire/release sandbox session + if (sandboxLifecycleHook != null) { + allHooks.add(sandboxLifecycleHook); + } + + if (agentTracingLogEnabled) { + allHooks.add(new AgentTraceHook()); + } + + WorkspaceContextHook markdownHook = + new WorkspaceContextHook( + wsManager, + name != null ? name : "HarnessAgent", + environmentMemory, + maxContextTokens); + markdownHook.setAdditionalContextFiles(additionalContextFiles); + allHooks.add(markdownHook); + + MemoryFlushHook memoryFlushHook = null; + if (model != null) { + memoryFlushHook = new MemoryFlushHook(wsManager, model); + allHooks.add(memoryFlushHook); + } + + if (model != null) { + MemoryConsolidator consolidator = new MemoryConsolidator(wsManager, model); + allHooks.add(new MemoryMaintenanceHook(wsManager, consolidator)); + } + + CompactionHook compactionHook = null; + if (compactionConfig != null && model != null) { + compactionHook = new CompactionHook(wsManager, model, compactionConfig); + allHooks.add(compactionHook); + } + + if (toolResultEvictionConfig != null) { + allHooks.add(new ToolResultEvictionHook(filesystem, toolResultEvictionConfig)); + } + + SessionPersistenceHook sessionPersistenceHook = new SessionPersistenceHook(); + allHooks.add(sessionPersistenceHook); + + if (!leafSubagent && model != null) { + SubagentsHook subagentsHook = + buildSubagentsHook(wsManager, resolvedWorkspace, capturedSandboxFs); + if (subagentsHook != null) { + allHooks.add(subagentsHook); + } + } + + // ---- Toolkit ---- + Toolkit agentToolkit = toolkit; + + MemorySearchTool searchTool = new MemorySearchTool(wsManager); + MemoryGetTool getTool = new MemoryGetTool(wsManager); + + agentToolkit.registerTool(searchTool); + agentToolkit.registerTool(getTool); + agentToolkit.registerTool(new SessionSearchTool(wsManager)); + + agentToolkit.registerTool(new FilesystemTool(filesystem)); + + if (filesystem instanceof AbstractSandboxFilesystem sandbox) { + agentToolkit.registerTool(new ShellExecuteTool(sandbox)); + } + + // ---- Skills (SkillBox assembled from optional AgentSkillRepository or default FS + // repo) ---- + SkillBox effectiveSkillBox = resolveSkillBox(wsManager, agentToolkit); + + // ---- Build ReActAgent ---- + ReActAgent.Builder reactBuilder = + ReActAgent.builder() + .name(name) + .description(description) + .sysPrompt(sysPrompt) + .model(model) + .toolkit(agentToolkit) + .memory(memory) + .maxIters(maxIters) + .hooks(allHooks); + + if (modelExecutionConfig != null) { + reactBuilder.modelExecutionConfig(modelExecutionConfig); + } + if (toolExecutionConfig != null) { + reactBuilder.toolExecutionConfig(toolExecutionConfig); + } + if (generateOptions != null) { + reactBuilder.generateOptions(generateOptions); + } + if (effectiveSkillBox != null) { + reactBuilder.skillBox(effectiveSkillBox); + } + if (toolExecutionContext != null) { + reactBuilder.toolExecutionContext(toolExecutionContext); + } + + ReActAgent delegate = reactBuilder.build(); + + log.info( + "HarnessAgent '{}' built [workspace={}, backend={}, subagents={}]", + name, + resolvedWorkspace, + filesystem.getClass().getSimpleName(), + !leafSubagent && model != null); + + return new HarnessAgent( + delegate, + wsManager, + compactionHook, + userIdRef, + sessionIdRef, + effectiveSession, + defaultSandboxContext); + } + + // @formatter:off + /** + * Subagent context section injected into every subagent's system prompt. + * Establishes identity, rules, output format, and prohibited behaviours for a leaf worker. + * The task itself is delivered as the first user message, not duplicated here. + */ + private static final String SUBAGENT_CONTEXT_SECTION = + """ + # Subagent Context + + You are a **subagent** spawned by the main agent for a specific task. + + ## Your Role + - Complete the assigned task. That's your entire purpose. + - You are NOT the main agent. Don't try to be. + + ## Rules + 1. **Stay focused** — Do your assigned task, nothing else + 2. **Complete the task** — Your final message will be automatically reported to the main agent + 3. **Don't initiate** — No heartbeats, no proactive actions, no side quests + 4. **Be ephemeral** — You may be terminated after task completion. That's fine. + 5. **Recover from truncated tool output** — If you see `[truncated: output exceeded context limit]`, re-read only what you need using smaller chunks (read with offset/limit, or targeted grep/head/tail) instead of full re-reads + + ## Output Format + When complete, your final response should include: + - What you accomplished or found + - Any relevant details the main agent should know + - Keep it concise but informative + + ## What You DON'T Do + - NO user conversations (that's the main agent's job) + - NO spawning further subagents — you are a leaf worker + - NO pretending to be the main agent + - Return plain text results; let the main agent deliver them to the user + """; + + // @formatter:on + + private static final String GENERAL_PURPOSE_BASE_PROMPT = + "You are a highly capable general-purpose subagent."; + + /** + * Builds a system prompt for a subagent by appending {@link #SUBAGENT_CONTEXT_SECTION} to + * the given base prompt. If the base is blank, only the context section is used. + */ + private static String buildSubagentSysPrompt(String basePrompt) { + String base = + (basePrompt != null && !basePrompt.isBlank()) ? basePrompt.stripTrailing() : ""; + return base.isEmpty() + ? SUBAGENT_CONTEXT_SECTION + : base + "\n\n" + SUBAGENT_CONTEXT_SECTION; + } + + // ----------------------------------------------------------------- + // Backend + // ----------------------------------------------------------------- + + private AbstractFilesystem resolveFilesystem( + Path workspace, + String agentId, + AtomicReference userIdRef, + AtomicReference sessionIdRef) { + if (abstractFilesystem != null) { + return abstractFilesystem; + } + NamespaceFactory nsFactory = buildDynamicNamespaceFactory(userIdRef); + if (remoteFilesystemSpec != null) { + return remoteFilesystemSpec.toFilesystem( + workspace, agentId, nsFactory, userIdRef::get, sessionIdRef::get); + } + if (localFilesystemSpec != null) { + return localFilesystemSpec.toFilesystem(workspace, nsFactory); + } + // Default to Mode 3 with out-of-the-box LocalFilesystemWithShell settings. + return new LocalFilesystemWithShell(workspace, nsFactory); + } + + private void validateDistributedSandboxConfig( + Session effectiveSession, SandboxContext sandboxContext) { + if (sandboxFilesystemSpec.getSandboxStateStore() == null + && effectiveSession instanceof WorkspaceSession) { + throw new IllegalStateException( + "filesystem(SandboxFilesystemSpec) requires a distributed Session backend" + + " (for example RedisSession) to persist and restore sandbox" + + " state across distributed instances." + + " Configure one via .session(...)." + + " For single-node use, opt out via" + + " .sandboxDistributed(SandboxDistributedOptions.builder()" + + ".requireDistributed(false).build())."); + } + if (sandboxContext == null + || sandboxContext.getSnapshotSpec() == null + || sandboxContext.getSnapshotSpec() instanceof NoopSnapshotSpec) { + throw new IllegalStateException( + "filesystem(SandboxFilesystemSpec) requires a non-noop snapshotSpec to" + + " restore workspace archives across distributed instances." + + " Configure one via SandboxFilesystemSpec.snapshotSpec(...)." + + " For single-node use, opt out via" + + " .sandboxDistributed(SandboxDistributedOptions.builder()" + + ".requireDistributed(false).build())."); + } + } + + private static NamespaceFactory buildDynamicNamespaceFactory( + AtomicReference userIdRef) { + return () -> { + String userId = userIdRef.get(); + if (userId == null || userId.isBlank()) { + return List.of(); + } + return List.of(userId); + }; + } + + // ----------------------------------------------------------------- + // Subagents + // ----------------------------------------------------------------- + + private SubagentsHook buildSubagentsHook( + WorkspaceManager wsManager, Path workspace, SandboxBackedFilesystem sandboxFs) { + List entries = buildSubagentEntries(workspace, sandboxFs); + TaskRepository repo = + taskRepository != null ? taskRepository : new DefaultTaskRepository(); + + if (externalSubagentTool != null) { + return new SubagentsHook(entries, externalSubagentTool, repo); + } + return new SubagentsHook(entries, repo, wsManager); + } + + /** + * Builds a factory for the general-purpose subagent. It creates a new HarnessAgent that + * mirrors the main agent's configuration (same model, workspace, file system, user hooks) + * but disables subagent support to prevent recursive spawning. + */ + private SubagentFactory buildGeneralPurposeFactory( + Path workspace, SandboxBackedFilesystem sandboxFs) { + // Capture builder state for the closure + final Model capturedModel = this.model; + final AbstractFilesystem capturedBackend = + sandboxFs != null ? sandboxFs : this.abstractFilesystem; + final int capturedMaxIters = this.maxIters; + final ExecutionConfig capturedModelExec = this.modelExecutionConfig; + final ExecutionConfig capturedToolExec = this.toolExecutionConfig; + final GenerateOptions capturedGenOpts = this.generateOptions; + final String capturedEnvMemory = this.environmentMemory; + final List capturedHooks = List.copyOf(this.hooks); + final AgentSkillRepository capturedSkillRepo = this.skillRepository; + final boolean capturedUseLegacyXmlWorkspaceContext = this.useLegacyXmlWorkspaceContext; + + return () -> { + Builder sub = + HarnessAgent.builder() + .name("general-purpose-subagent") + .description("General-purpose subagent for isolated task execution") + .sysPrompt(buildSubagentSysPrompt(GENERAL_PURPOSE_BASE_PROMPT)) + .model(capturedModel) + .workspace(workspace) + .asLeafSubagent() + .maxIters(capturedMaxIters) + .environmentMemory(capturedEnvMemory) + .useLegacyXmlWorkspaceContext(capturedUseLegacyXmlWorkspaceContext); + + if (capturedSkillRepo != null) { + sub.skillRepository(capturedSkillRepo); + } + if (capturedBackend != null) { + sub.abstractFilesystem(capturedBackend); + } + if (capturedModelExec != null) { + sub.modelExecutionConfig(capturedModelExec); + } + if (capturedToolExec != null) { + sub.toolExecutionConfig(capturedToolExec); + } + if (capturedGenOpts != null) { + sub.generateOptions(capturedGenOpts); + } + sub.hooks(capturedHooks); + + return sub.build(); + }; + } + + /** + * Builds a factory for a spec-based subagent. The resulting HarnessAgent is fully + * independent from the main agent — it uses the spec's own system prompt, workspace, + * and configuration. Supports per-subagent {@code model} override via an explicit {@code + * modelResolver}, or by default {@link ModelRegistry#resolve(String)}. + */ + private SubagentFactory buildSpecFactory(SubagentSpec spec, Path defaultWorkspace) { + final Model capturedModel = this.model; + final Function capturedResolver = this.modelResolver; + final AgentSkillRepository capturedSkillRepo = this.skillRepository; + final boolean capturedUseLegacyXmlWorkspaceContext = this.useLegacyXmlWorkspaceContext; + + return () -> { + Path specWorkspace = + (spec.getWorkspace() != null && !spec.getWorkspace().isBlank()) + ? Path.of(spec.getWorkspace()) + : defaultWorkspace; + + Function effectiveResolver = + capturedResolver != null ? capturedResolver : ModelRegistry::resolve; + + Model effectiveModel = capturedModel; + if (spec.getModel() != null && !spec.getModel().isBlank()) { + String specModel = spec.getModel().trim(); + if (ModelRegistry.canResolve(specModel) || capturedResolver != null) { + try { + Model resolved = effectiveResolver.apply(specModel); + if (resolved != null) { + effectiveModel = resolved; + log.debug( + "Subagent '{}' using overridden model: {}", + spec.getName(), + spec.getModel()); + } + } catch (Exception e) { + log.warn( + "Failed to resolve model '{}' for subagent '{}', falling back" + + " to parent model: {}", + spec.getModel(), + spec.getName(), + e.getMessage()); + } + } + } + + Builder sub = + HarnessAgent.builder() + .name(spec.getName()) + .description( + spec.getDescription() != null ? spec.getDescription() : "") + .model(effectiveModel) + .workspace(specWorkspace) + .maxIters(spec.getMaxIters()) + .asLeafSubagent() + .useLegacyXmlWorkspaceContext(capturedUseLegacyXmlWorkspaceContext); + + if (capturedSkillRepo != null) { + sub.skillRepository(capturedSkillRepo); + } + sub.sysPrompt(buildSubagentSysPrompt(spec.getSysPrompt())); + + return sub.build(); + }; + } + + // ----------------------------------------------------------------- + // Skills + // ----------------------------------------------------------------- + + private SkillBox resolveSkillBox(WorkspaceManager wsManager, Toolkit agentToolkit) { + if (skillRepository != null) { + return skillBoxFromRepository(skillRepository, agentToolkit); + } + Path skillsDir = wsManager.getSkillsDir(); + if (!Files.isDirectory(skillsDir)) { + return null; + } + try { + return skillBoxFromRepository( + new FileSystemSkillRepository(skillsDir), agentToolkit); + } catch (Exception e) { + log.warn("Failed to auto-load skills from {}: {}", skillsDir, e.getMessage()); + return null; + } + } + + private static SkillBox skillBoxFromRepository( + AgentSkillRepository repo, Toolkit agentToolkit) { + try { + List skills = repo.getAllSkills(); + if (skills == null || skills.isEmpty()) { + return null; + } + SkillBox box = new SkillBox(agentToolkit); + for (AgentSkill skill : skills) { + box.registerSkill(skill); + } + log.info( + "Loaded {} skills from {}", + skills.size(), + repo.getRepositoryInfo() != null + ? repo.getRepositoryInfo() + : repo.getClass().getSimpleName()); + return box; + } catch (Exception e) { + log.warn("Failed to load skills from repository: {}", e.getMessage()); + return null; + } + } + + private record SubagentFactoryEntry(String name, Function factory) {} + + /** Marks this build as a leaf subagent (no nested subagent orchestration). */ + private Builder asLeafSubagent() { + this.leafSubagent = true; + return this; + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/IsolationScope.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/IsolationScope.java new file mode 100644 index 000000000..6df694a26 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/IsolationScope.java @@ -0,0 +1,83 @@ +/* + * 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; + +/** + * Controls how agent state is isolated and shared across calls. + * + *

    This enum is the canonical isolation-scope definition used by both the sandbox filesystem + * backend ({@link io.agentscope.harness.agent.sandbox.SandboxContext}) and the remote filesystem + * backend ({@link io.agentscope.harness.agent.filesystem.RemoteFilesystemSpec}). + * + *

    Sandbox semantics: the scope determines which key is used when persisting and loading + * {@code _sandbox.json} state. Calls that resolve to the same scope key will + * sequentially reuse the same sandbox (each call resumes the persisted state from the previous + * one). + * + *

    Store namespace semantics: the scope determines the namespace prefix used by + * {@link io.agentscope.harness.agent.filesystem.RemoteFilesystem} when routing files to the shared + * key-value store. Different scopes produce different namespace prefixes, controlling which calls + * share the same view of stored files. + * + *

    Scope selection: + *

      + *
    • {@link #SESSION} – isolated per session; the default.
    • + *
    • {@link #USER} – shared across all sessions of the same user.
    • + *
    • {@link #AGENT} – shared across all users and sessions of the same agent.
    • + *
    • {@link #GLOBAL} – globally shared within the same workspace/store instance.
    • + *
    + * + *

    Concurrency note: for sandbox mode this is sequential-reuse sharing, not + * live-instance sharing. Concurrent calls at the same scope each get their own running container; + * they converge on the last persisted snapshot at the end of the call. + */ +public enum IsolationScope { + + /** + * Isolate by session identifier. + * + *

    This is the default behavior. Each distinct session gets its own sandbox state / + * store namespace. If no session key is present in the + * {@link io.agentscope.core.agent.RuntimeContext}, state lookup is skipped and a fresh + * sandbox is created (or a default store namespace is used). + */ + SESSION, + + /** + * Share across all sessions belonging to the same + * {@link io.agentscope.core.agent.RuntimeContext#getUserId() userId}. + * + *

    If {@code userId} is blank, a warning is logged and state lookup / namespace resolution + * degrades to the default (fresh sandbox create, or an anonymous-user namespace). + */ + USER, + + /** + * Share across all users and sessions of the same agent (identified by agent name). + * + *

    The agent name is fixed at build time and is always available; this scope never + * degrades due to a missing context field. + */ + AGENT, + + /** + * One shared state / namespace globally within the same workspace store instance. + * + *

    Use with care: all agents and users that share the same store will compete to write + * the global slot. + */ + GLOBAL +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/AbstractFilesystem.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/AbstractFilesystem.java new file mode 100644 index 000000000..815bed1f6 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/AbstractFilesystem.java @@ -0,0 +1,184 @@ +/* + * 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.filesystem; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.filesystem.model.EditResult; +import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse; +import io.agentscope.harness.agent.filesystem.model.FileUploadResponse; +import io.agentscope.harness.agent.filesystem.model.GlobResult; +import io.agentscope.harness.agent.filesystem.model.GrepResult; +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 java.util.List; +import java.util.Map; + +/** + * Abstract filesystem API for agents: list, read, write, edit, grep, glob, upload, download. + * + *

    Implementations may target the local disk, a sandbox, a key-value store, or other storage. + * Host-rooted types {@link LocalFilesystem} and {@link LocalFilesystemWithShell} also expose + * constructors that take the workspace root as a {@link String} path (same semantics as + * {@link java.nio.file.Path}). + * + *

    Every operation accepts a {@link RuntimeContext} so backends can scope work to the current + * session, user, or sandbox. Callers that are not inside a tool/agent call with a merged context + * should pass {@link RuntimeContext#empty()}. + */ +public interface AbstractFilesystem { + + /** + * List all files in a directory with metadata. + * + * @param runtimeContext per-call agent runtime; {@link RuntimeContext#empty()} when none + * @param path absolute path to the directory to list (must start with '/') + * @return LsResult with directory entries or error + */ + LsResult ls(RuntimeContext runtimeContext, String path); + + /** + * Read file content with optional line-based pagination. + * + * @param runtimeContext per-call agent runtime; {@link RuntimeContext#empty()} when none + * @param filePath absolute path to the file to read (must start with '/') + * @param offset line number to start reading from (0-indexed). Default: 0 + * @param limit maximum number of lines to read. Default: 2000 + * @return ReadResult with file data on success or error on failure + */ + ReadResult read(RuntimeContext runtimeContext, String filePath, int offset, int limit); + + /** + * Write content to a new file, error if file already exists. + * + * @param runtimeContext per-call agent runtime; {@link RuntimeContext#empty()} when none + * @param filePath absolute path where the file should be created + * @param content string content to write to the file + * @return WriteResult with path on success, or error if the file already exists or write fails + */ + WriteResult write(RuntimeContext runtimeContext, String filePath, String content); + + /** + * Perform exact string replacements in an existing file. + * + * @param runtimeContext per-call agent runtime; {@link RuntimeContext#empty()} when none + * @param filePath absolute path to the file to edit + * @param oldString exact string to search for and replace + * @param newString string to replace oldString with (must be different from oldString) + * @param replaceAll if true, replace all occurrences; if false, oldString must be unique + * @return EditResult with path and occurrence count on success, or error on failure + */ + EditResult edit( + RuntimeContext runtimeContext, + String filePath, + String oldString, + String newString, + boolean replaceAll); + + /** + * Search for a literal text pattern in files. + * + * @param runtimeContext per-call agent runtime; {@link RuntimeContext#empty()} when none + * @param pattern literal string to search for (not regex) + * @param path optional directory path to search in (null searches current working directory) + * @param glob optional glob pattern to filter which files to search (e.g., "*.java") + * @return GrepResult with matches or error + */ + GrepResult grep(RuntimeContext runtimeContext, String pattern, String path, String glob); + + /** + * Find files matching a glob pattern. + * + * @param runtimeContext per-call agent runtime; {@link RuntimeContext#empty()} when none + * @param pattern glob pattern with wildcards to match file paths + * @param path base directory to search from (default: "/") + * @return GlobResult with matching files or error + */ + GlobResult glob(RuntimeContext runtimeContext, String pattern, String path); + + /** + * Upload multiple files. + * + * @param runtimeContext per-call agent runtime; {@link RuntimeContext#empty()} when none + * @param files list of path-to-content mappings to upload + * @return list of FileUploadResponse objects, one per input file (order matches input order) + */ + List uploadFiles( + RuntimeContext runtimeContext, List> files); + + /** + * Download multiple files. + * + * @param runtimeContext per-call agent runtime; {@link RuntimeContext#empty()} when none + * @param paths list of file paths to download + * @return list of FileDownloadResponse objects, one per input path (order matches input order) + */ + List downloadFiles(RuntimeContext runtimeContext, List paths); + + /** + * Delete a file or directory (recursive for directories). + * + *

    Idempotent: deleting a path that does not exist is treated as success. + * + * @param runtimeContext per-call agent runtime; {@link RuntimeContext#empty()} when none + * @param path absolute path to the file or directory to delete + * @return WriteResult success when deleted (or already absent), failure on I/O error + */ + WriteResult delete(RuntimeContext runtimeContext, String path); + + /** + * Move (rename) a file or directory from {@code fromPath} to {@code toPath}. + * + *

    Implementations that span multiple backends (e.g. {@code CompositeFilesystem}) may + * fall back to a read + write + delete sequence when source and destination live in + * different backend filesystems. + * + * @param runtimeContext per-call agent runtime; {@link RuntimeContext#empty()} when none + * @param fromPath absolute source path + * @param toPath absolute destination path + * @return WriteResult success on completion, failure on I/O error or missing source + */ + WriteResult move(RuntimeContext runtimeContext, String fromPath, String toPath); + + /** + * Check whether a file or directory exists. + * + *

    Implementations may approximate this with a lightweight read probe where a dedicated + * {@code exists} API is unavailable, but should avoid reading full file content. + * + * @param runtimeContext per-call agent runtime; {@link RuntimeContext#empty()} when none + * @param path absolute path to check + * @return {@code true} if the path exists, {@code false} otherwise + */ + boolean exists(RuntimeContext runtimeContext, String path); + + // ==================== Path validation utility ==================== + + /** + * Validates that {@code path} is safe (non-null, non-blank, no {@code ..} traversal). + * + * @param path the path to validate + * @throws IllegalArgumentException if the path is invalid + */ + static void validatePath(String path) { + if (path == null || path.isBlank()) { + throw new IllegalArgumentException("Path must not be null or blank"); + } + if (path.contains("..")) { + throw new IllegalArgumentException("Path traversal ('..') not allowed: " + path); + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/AbstractSandboxFilesystem.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/AbstractSandboxFilesystem.java new file mode 100644 index 000000000..b33230445 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/AbstractSandboxFilesystem.java @@ -0,0 +1,43 @@ +/* + * 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.filesystem; + +import io.agentscope.harness.agent.filesystem.model.ExecuteResponse; + +/** + * Filesystem abstraction that adds shell command execution (sandbox or remote host). + * + *

    Extends {@link AbstractFilesystem} with {@link #execute} and {@link #id()}. + */ +public interface AbstractSandboxFilesystem extends AbstractFilesystem { + + /** + * Unique identifier for this filesystem/sandbox instance. + * + * @return id string + */ + String id(); + + /** + * Execute a shell command in the environment backing this filesystem. + * + * @param command full shell command string to execute + * @param timeoutSeconds maximum time in seconds to wait for the command to complete; + * {@code null} uses the filesystem's default timeout + * @return ExecuteResponse with combined output, exit code, and truncation flag + */ + ExecuteResponse execute(String command, Integer timeoutSeconds); +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/BaseSandboxFilesystem.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/BaseSandboxFilesystem.java new file mode 100644 index 000000000..9e6cdba96 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/BaseSandboxFilesystem.java @@ -0,0 +1,425 @@ +/* + * 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.filesystem; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.filesystem.model.EditResult; +import io.agentscope.harness.agent.filesystem.model.ExecuteResponse; +import io.agentscope.harness.agent.filesystem.model.FileData; +import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse; +import io.agentscope.harness.agent.filesystem.model.FileInfo; +import io.agentscope.harness.agent.filesystem.model.FileUploadResponse; +import io.agentscope.harness.agent.filesystem.model.GlobResult; +import io.agentscope.harness.agent.filesystem.model.GrepMatch; +import io.agentscope.harness.agent.filesystem.model.GrepResult; +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.filesystem.util.FilesystemUtils; +import io.agentscope.harness.agent.store.NamespaceFactory; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; + +/** + * Abstract base sandbox implementation with {@link #execute} as the core abstract method. + * + *

    This class provides default implementations for all {@link AbstractFilesystem} methods by + * delegating + * to shell commands via {@link #execute}. File listing, grep, and glob use standard Unix + * commands. Read uses server-side commands for paginated access. Write delegates content + * transfer to {@link #uploadFiles}. Edit uses server-side commands for string replacement. + * + *

    Subclasses must implement: + *

      + *
    • {@link #execute} - execute a command in the sandbox
    • + *
    • {@link #uploadFiles} - upload files to the sandbox
    • + *
    • {@link #downloadFiles} - download files from the sandbox
    • + *
    • {@link #id()} - unique identifier for the sandbox instance
    • + *
    + */ +public abstract class BaseSandboxFilesystem implements AbstractSandboxFilesystem { + + private NamespaceFactory namespaceFactory; + + protected NamespaceFactory getNamespaceFactory() { + return namespaceFactory; + } + + protected void setNamespaceFactory(NamespaceFactory namespaceFactory) { + this.namespaceFactory = namespaceFactory; + } + + protected String namespacedPath(String path) { + if (namespaceFactory == null || path == null || path.isBlank()) { + return path; + } + List ns = namespaceFactory.getNamespace(); + if (ns == null || ns.isEmpty()) { + return path; + } + String prefix = String.join("/", ns); + if (path.startsWith("/")) { + return "/" + prefix + path; + } + return prefix + "/" + path; + } + + @Override + public abstract String id(); + + @Override + public abstract ExecuteResponse execute(String command, Integer timeoutSeconds); + + @Override + public abstract List uploadFiles( + RuntimeContext runtimeContext, List> files); + + @Override + public abstract List downloadFiles( + RuntimeContext runtimeContext, List paths); + + @Override + public LsResult ls(RuntimeContext runtimeContext, String path) { + String escapedPath = FilesystemUtils.shellQuote(namespacedPath(path)); + String cmd = + "for f in " + + escapedPath + + "/*; do " + + " if [ -d \"$f\" ]; then echo \"DIR:$f\"; " + + " elif [ -f \"$f\" ]; then echo \"FILE:$f\"; fi; " + + "done 2>/dev/null"; + + ExecuteResponse result = execute(cmd, null); + List entries = new ArrayList<>(); + + if (result.output() != null && !result.output().isBlank()) { + for (String line : result.output().strip().split("\n")) { + if (line.startsWith("DIR:")) { + entries.add(FileInfo.ofDir(line.substring(4), "")); + } else if (line.startsWith("FILE:")) { + entries.add(FileInfo.ofFile(line.substring(5), 0, "")); + } + } + } + + return LsResult.success(entries); + } + + @Override + public ReadResult read(RuntimeContext runtimeContext, String filePath, int offset, int limit) { + String nsPath = namespacedPath(filePath); + String fileType = FilesystemUtils.getFileType(nsPath); + String escapedPath = FilesystemUtils.shellQuote(nsPath); + + if (!"text".equals(fileType)) { + String cmd = "base64 " + escapedPath + " 2>/dev/null"; + ExecuteResponse result = execute(cmd, null); + if (result.exitCode() != null && result.exitCode() != 0) { + return ReadResult.fail("File '" + filePath + "': file_not_found"); + } + String encoded = result.output() != null ? result.output().strip() : ""; + return ReadResult.success(new FileData(encoded, "base64")); + } + + int startLine = offset + 1; + int endLine = limit > 0 ? offset + limit : Integer.MAX_VALUE; + String cmd = + "if [ ! -f " + + escapedPath + + " ]; then echo '__NOT_FOUND__'; " + + "elif [ ! -s " + + escapedPath + + " ]; then echo '__EMPTY__'; " + + "else sed -n '" + + startLine + + "," + + endLine + + "p' " + + escapedPath + + "; fi"; + + ExecuteResponse result = execute(cmd, null); + String output = result.output() != null ? result.output() : ""; + + if (output.strip().equals("__NOT_FOUND__")) { + return ReadResult.fail("File '" + filePath + "': file_not_found"); + } + if (output.strip().equals("__EMPTY__")) { + return ReadResult.success( + new FileData("System reminder: File exists but has empty contents", "utf-8")); + } + + if (output.endsWith("\n")) { + output = output.substring(0, output.length() - 1); + } + return ReadResult.success(new FileData(output, "utf-8")); + } + + @Override + public WriteResult write(RuntimeContext runtimeContext, String filePath, String content) { + String nsPath = namespacedPath(filePath); + String escapedPath = FilesystemUtils.shellQuote(nsPath); + String checkCmd = + "if [ -e " + + escapedPath + + " ]; then echo 'EXISTS'; exit 1; fi; " + + "mkdir -p \"$(dirname " + + escapedPath + + ")\" 2>&1"; + + ExecuteResponse checkResult = execute(checkCmd, null); + if (checkResult.exitCode() != null && checkResult.exitCode() != 0) { + if (checkResult.output() != null && checkResult.output().contains("EXISTS")) { + return WriteResult.fail( + "Cannot write to " + + filePath + + " because it already exists. Read and then make an" + + " edit, or write to a new path."); + } + return WriteResult.fail("Failed to write file '" + filePath + "'"); + } + + List responses = + uploadFiles( + runtimeContext, + List.of( + Map.entry( + nsPath, + content.getBytes( + java.nio.charset.StandardCharsets.UTF_8)))); + if (responses.isEmpty() || !responses.get(0).isSuccess()) { + String err = + responses.isEmpty() ? "upload returned no response" : responses.get(0).error(); + return WriteResult.fail("Failed to write file '" + filePath + "': " + err); + } + + return WriteResult.ok(filePath); + } + + @Override + public EditResult edit( + RuntimeContext runtimeContext, + String filePath, + String oldString, + String newString, + boolean replaceAll) { + String nsPath = namespacedPath(filePath); + String payload = + "{\"path\":\"" + + jsonEscape(nsPath) + + "\"," + + "\"old\":\"" + + jsonEscape(oldString) + + "\"," + + "\"new\":\"" + + jsonEscape(newString) + + "\"," + + "\"replace_all\":" + + replaceAll + + "}"; + String payloadB64 = + Base64.getEncoder() + .encodeToString(payload.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + + String cmd = + "python3 -c \"import sys, os, base64, json\\n" + + "payload =" + + " json.loads(base64.b64decode(sys.stdin.read().strip()).decode('utf-8'))\\n" + + "path, old, new = payload['path'], payload['old'], payload['new']\\n" + + "replace_all = payload.get('replace_all', False)\\n" + + "if not os.path.isfile(path):\\n" + + " print(json.dumps({'error': 'file_not_found'}))\\n" + + " sys.exit(0)\\n" + + "with open(path, 'rb') as f: text = f.read().decode('utf-8')\\n" + + "count = text.count(old)\\n" + + "if count == 0:\\n" + + " print(json.dumps({'error': 'string_not_found'}))\\n" + + " sys.exit(0)\\n" + + "if count > 1 and not replace_all:\\n" + + " print(json.dumps({'error': 'multiple_occurrences', 'count': count}))\\n" + + " sys.exit(0)\\n" + + "result = text.replace(old, new) if replace_all else text.replace(old, new," + + " 1)\\n" + + "with open(path, 'wb') as f: f.write(result.encode('utf-8'))\\n" + + "print(json.dumps({'count': count}))\\n" + + "\" 2>&1 <<'__EDIT_EOF__'\n" + + payloadB64 + + "\n__EDIT_EOF__\n"; + + ExecuteResponse result = execute(cmd, null); + String output = result.output() != null ? result.output().strip() : ""; + + if (output.contains("\"error\"")) { + if (output.contains("file_not_found")) { + return EditResult.fail("Error: File '" + filePath + "' not found"); + } + if (output.contains("string_not_found")) { + return EditResult.fail("Error: String not found in file: '" + oldString + "'"); + } + if (output.contains("multiple_occurrences")) { + return EditResult.fail( + "Error: String '" + + oldString + + "' appears multiple times. Use replaceAll=true to replace all" + + " occurrences."); + } + return EditResult.fail("Error editing file '" + filePath + "': " + output); + } + + if (output.contains("\"count\"")) { + try { + int countIdx = output.indexOf("\"count\":") + 8; + int endIdx = output.indexOf('}', countIdx); + int count = Integer.parseInt(output.substring(countIdx, endIdx).trim()); + return EditResult.ok(filePath, count); + } catch (NumberFormatException e) { + return EditResult.ok(filePath, 1); + } + } + + return EditResult.fail( + "Error editing file '" + + filePath + + "': unexpected server response: " + + output.substring(0, Math.min(200, output.length()))); + } + + @Override + public GrepResult grep( + RuntimeContext runtimeContext, String pattern, String path, String glob) { + String nsPath = path != null ? namespacedPath(path) : "."; + String searchPath = FilesystemUtils.shellQuote(nsPath); + String grepOpts = "-rHnF"; + String globPattern = ""; + if (glob != null && !glob.isBlank()) { + globPattern = "--include=" + FilesystemUtils.shellQuote(glob); + } + String patternEscaped = FilesystemUtils.shellQuote(pattern); + + String cmd = + "grep " + + grepOpts + + " " + + globPattern + + " -e " + + patternEscaped + + " " + + searchPath + + " 2>/dev/null || true"; + + ExecuteResponse result = execute(cmd, null); + String output = result.output() != null ? result.output().strip() : ""; + + if (output.isEmpty()) { + return GrepResult.success(List.of()); + } + + List matches = new ArrayList<>(); + for (String line : output.split("\n")) { + String[] parts = line.split(":", 3); + if (parts.length >= 3) { + try { + matches.add(new GrepMatch(parts[0], Integer.parseInt(parts[1]), parts[2])); + } catch (NumberFormatException e) { + // skip malformed lines + } + } + } + + return GrepResult.success(matches); + } + + @Override + public GlobResult glob(RuntimeContext runtimeContext, String pattern, String path) { + String effectivePath = path != null ? namespacedPath(path) : "/"; + String escapedPath = FilesystemUtils.shellQuote(effectivePath); + String escapedPattern = FilesystemUtils.shellQuote(pattern); + + String cmd = + "find " + escapedPath + " -type f -name " + escapedPattern + " 2>/dev/null | sort"; + + ExecuteResponse result = execute(cmd, null); + String output = result.output() != null ? result.output().strip() : ""; + + if (output.isEmpty()) { + return GlobResult.success(List.of()); + } + + List entries = new ArrayList<>(); + for (String line : output.split("\n")) { + if (!line.isBlank()) { + entries.add(FileInfo.ofFile(line.trim(), 0, "")); + } + } + + return GlobResult.success(entries); + } + + @Override + public WriteResult delete(RuntimeContext runtimeContext, String path) { + AbstractFilesystem.validatePath(path); + String effectivePath = namespacedPath(path); + String escapedPath = FilesystemUtils.shellQuote(effectivePath); + String cmd = "rm -rf " + escapedPath; + ExecuteResponse result = execute(cmd, null); + if (result.exitCode() != 0) { + return WriteResult.fail("Error deleting '" + path + "': " + result.output()); + } + return WriteResult.ok(path); + } + + @Override + public WriteResult move(RuntimeContext runtimeContext, String fromPath, String toPath) { + AbstractFilesystem.validatePath(fromPath); + AbstractFilesystem.validatePath(toPath); + String from = namespacedPath(fromPath); + String to = namespacedPath(toPath); + String escapedFrom = FilesystemUtils.shellQuote(from); + String escapedTo = FilesystemUtils.shellQuote(to); + String cmd = "mkdir -p $(dirname " + escapedTo + ") && mv " + escapedFrom + " " + escapedTo; + ExecuteResponse result = execute(cmd, null); + if (result.exitCode() != 0) { + return WriteResult.fail( + "Error moving '" + fromPath + "' to '" + toPath + "': " + result.output()); + } + return WriteResult.ok(toPath); + } + + @Override + public boolean exists(RuntimeContext runtimeContext, String path) { + if (path == null || path.isBlank()) { + return false; + } + String effectivePath = namespacedPath(path); + String escapedPath = FilesystemUtils.shellQuote(effectivePath); + ExecuteResponse result = + execute("test -e " + escapedPath + " && echo yes || echo no", null); + return result.output() != null && result.output().strip().startsWith("yes"); + } + + private static String jsonEscape(String s) { + if (s == null) { + return ""; + } + return s.replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("\n", "\\n") + .replace("\r", "\\r") + .replace("\t", "\\t"); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/CompositeFilesystem.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/CompositeFilesystem.java new file mode 100644 index 000000000..3e57391a3 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/CompositeFilesystem.java @@ -0,0 +1,428 @@ +/* + * 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.filesystem; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.filesystem.model.EditResult; +import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse; +import io.agentscope.harness.agent.filesystem.model.FileInfo; +import io.agentscope.harness.agent.filesystem.model.FileUploadResponse; +import io.agentscope.harness.agent.filesystem.model.GlobResult; +import io.agentscope.harness.agent.filesystem.model.GrepMatch; +import io.agentscope.harness.agent.filesystem.model.GrepResult; +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 java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Routes file operations to different {@link AbstractFilesystem} backends by path prefix. + * + *

    Paths are matched against route prefixes (longest first). Unmatched paths fall through to the + * default backend. + * + *

    Composite deliberately implements only {@link AbstractFilesystem} — it is the unified, + * non-sandbox view that blends a local workspace with remote-store-backed paths. Shell execution + * is intentionally not supported in this mode: routing shell commands across backends is + * ambiguous, and the primary use case (distributed memory with per-user/session isolation) does + * not need it. If you need shell execution, use a sandbox-backed filesystem + * ({@link AbstractSandboxFilesystem}) or {@link LocalFilesystemWithShell} directly instead. + * + *

    Example: + * + *

    {@code
    + * CompositeFilesystem fs = new CompositeFilesystem(
    + *     localFs,
    + *     Map.of("/memories/", storeFs, "/cache/", inMemoryFs)
    + * );
    + * fs.read(RuntimeContext.empty(), "/memories/notes.md", 0, 100);  // → storeFs.read(...)
    + * fs.read(RuntimeContext.empty(), "/src/Main.java", 0, 100);      // → localFs.read(...)
    + * }
    + */ +public class CompositeFilesystem implements AbstractFilesystem { + + private final AbstractFilesystem defaultBackend; + private final List sortedRoutes; + + /** + * Creates a composite filesystem with a default backend and prefix-based routes. + * + * @param defaultBackend backend for paths that don't match any route + * @param routes map of path prefixes to backends; prefixes must start with {@code "/"} + * and should end with {@code "/"} (e.g. {@code "/memories/"}) + */ + public CompositeFilesystem( + AbstractFilesystem defaultBackend, Map routes) { + if (defaultBackend == null) { + throw new IllegalArgumentException("defaultBackend must not be null"); + } + this.defaultBackend = defaultBackend; + + List entries = new ArrayList<>(); + if (routes != null) { + for (Map.Entry e : routes.entrySet()) { + entries.add(new RouteEntry(e.getKey(), e.getValue())); + } + } + entries.sort(Comparator.comparingInt((RouteEntry e) -> e.prefix().length()).reversed()); + this.sortedRoutes = List.copyOf(entries); + } + + // ==================== Routing ==================== + + private record RouteEntry(String prefix, AbstractFilesystem backend) {} + + private record RouteResult( + AbstractFilesystem backend, String backendPath, String routePrefix) {} + + private RouteResult routeForPath(String path) { + for (RouteEntry entry : sortedRoutes) { + String prefixNoSlash = + entry.prefix().endsWith("/") + ? entry.prefix().substring(0, entry.prefix().length() - 1) + : entry.prefix(); + if (path.equals(prefixNoSlash)) { + if (entry.prefix().endsWith("/")) { + return new RouteResult(entry.backend(), "/", entry.prefix()); + } + String backendPath = path.startsWith("/") ? path : "/" + path; + return new RouteResult(entry.backend(), backendPath, entry.prefix()); + } + String normalizedPrefix = + entry.prefix().endsWith("/") ? entry.prefix() : entry.prefix() + "/"; + if (path.startsWith(normalizedPrefix)) { + String suffix = path.substring(normalizedPrefix.length()); + String backendPath = suffix.isEmpty() ? "/" : "/" + suffix; + return new RouteResult(entry.backend(), backendPath, entry.prefix()); + } + } + return new RouteResult(defaultBackend, path, null); + } + + // ==================== Path remapping helpers ==================== + + private static String prependRoute(String routePrefix, String backendPath) { + String base = + routePrefix.endsWith("/") + ? routePrefix.substring(0, routePrefix.length() - 1) + : routePrefix; + return base + backendPath; + } + + private static FileInfo remapFileInfo(FileInfo fi, String routePrefix) { + return new FileInfo( + prependRoute(routePrefix, fi.path()), fi.isDirectory(), fi.size(), fi.modifiedAt()); + } + + private static GrepMatch remapGrepMatch(GrepMatch m, String routePrefix) { + return new GrepMatch(prependRoute(routePrefix, m.path()), m.line(), m.text()); + } + + private static String stripRouteFromPattern(String pattern, String routePrefix) { + String barePattern = pattern.startsWith("/") ? pattern.substring(1) : pattern; + String barePrefix = routePrefix.replaceAll("^/|/$", "") + "/"; + if (barePattern.startsWith(barePrefix)) { + return barePattern.substring(barePrefix.length()); + } + return pattern; + } + + // ==================== AbstractFilesystem ==================== + + @Override + public LsResult ls(RuntimeContext runtimeContext, String path) { + RouteResult route = routeForPath(path); + + if (route.routePrefix() != null) { + LsResult result = route.backend().ls(runtimeContext, route.backendPath()); + if (!result.isSuccess()) { + return result; + } + List remapped = new ArrayList<>(); + for (FileInfo fi : result.entries()) { + remapped.add(remapFileInfo(fi, route.routePrefix())); + } + return LsResult.success(remapped); + } + + if ("/".equals(path)) { + List results = new ArrayList<>(); + LsResult defaultResult = defaultBackend.ls(runtimeContext, path); + if (defaultResult.isSuccess() && defaultResult.entries() != null) { + results.addAll(defaultResult.entries()); + } + for (RouteEntry entry : sortedRoutes) { + if (!entry.prefix().endsWith("/")) { + continue; + } + String dirPath = + entry.prefix().endsWith("/") ? entry.prefix() : entry.prefix() + "/"; + results.add(FileInfo.ofDir(dirPath, "")); + } + results.sort(Comparator.comparing(FileInfo::path)); + return LsResult.success(results); + } + + return defaultBackend.ls(runtimeContext, path); + } + + @Override + public ReadResult read(RuntimeContext runtimeContext, String filePath, int offset, int limit) { + RouteResult route = routeForPath(filePath); + return route.backend().read(runtimeContext, route.backendPath(), offset, limit); + } + + @Override + public WriteResult write(RuntimeContext runtimeContext, String filePath, String content) { + RouteResult route = routeForPath(filePath); + WriteResult result = route.backend().write(runtimeContext, route.backendPath(), content); + if (result.isSuccess() && route.routePrefix() != null) { + return WriteResult.ok(filePath); + } + return result; + } + + @Override + public EditResult edit( + RuntimeContext runtimeContext, + String filePath, + String oldString, + String newString, + boolean replaceAll) { + RouteResult route = routeForPath(filePath); + EditResult result = + route.backend() + .edit( + runtimeContext, + route.backendPath(), + oldString, + newString, + replaceAll); + if (result.isSuccess() && route.routePrefix() != null) { + return EditResult.ok(filePath, result.occurrences()); + } + return result; + } + + @Override + public GrepResult grep( + RuntimeContext runtimeContext, String pattern, String path, String glob) { + if (path != null) { + RouteResult route = routeForPath(path); + if (route.routePrefix() != null) { + GrepResult result = + route.backend().grep(runtimeContext, pattern, route.backendPath(), glob); + if (!result.isSuccess()) { + return result; + } + List remapped = new ArrayList<>(); + for (GrepMatch m : result.matches()) { + remapped.add(remapGrepMatch(m, route.routePrefix())); + } + return GrepResult.success(remapped); + } + } + + if (path == null || "/".equals(path)) { + List allMatches = new ArrayList<>(); + GrepResult defaultResult = defaultBackend.grep(runtimeContext, pattern, path, glob); + if (!defaultResult.isSuccess()) { + return defaultResult; + } + if (defaultResult.matches() != null) { + allMatches.addAll(defaultResult.matches()); + } + for (RouteEntry entry : sortedRoutes) { + GrepResult routeResult = entry.backend().grep(runtimeContext, pattern, "/", glob); + if (!routeResult.isSuccess()) { + return routeResult; + } + if (routeResult.matches() != null) { + for (GrepMatch m : routeResult.matches()) { + allMatches.add(remapGrepMatch(m, entry.prefix())); + } + } + } + return GrepResult.success(allMatches); + } + + return defaultBackend.grep(runtimeContext, pattern, path, glob); + } + + @Override + public GlobResult glob(RuntimeContext runtimeContext, String pattern, String path) { + RouteResult route = routeForPath(path); + + if (route.routePrefix() != null) { + GlobResult result = route.backend().glob(runtimeContext, pattern, route.backendPath()); + if (!result.isSuccess()) { + return result; + } + List remapped = new ArrayList<>(); + for (FileInfo fi : result.matches()) { + remapped.add(remapFileInfo(fi, route.routePrefix())); + } + return GlobResult.success(remapped); + } + + List results = new ArrayList<>(); + GlobResult defaultResult = defaultBackend.glob(runtimeContext, pattern, path); + if (defaultResult.isSuccess() && defaultResult.matches() != null) { + results.addAll(defaultResult.matches()); + } + for (RouteEntry entry : sortedRoutes) { + String routePattern = stripRouteFromPattern(pattern, entry.prefix()); + GlobResult routeResult = entry.backend().glob(runtimeContext, routePattern, "/"); + if (routeResult.isSuccess() && routeResult.matches() != null) { + for (FileInfo fi : routeResult.matches()) { + results.add(remapFileInfo(fi, entry.prefix())); + } + } + } + results.sort(Comparator.comparing(FileInfo::path)); + return GlobResult.success(results); + } + + @Override + public List uploadFiles( + RuntimeContext runtimeContext, List> files) { + FileUploadResponse[] results = new FileUploadResponse[files.size()]; + Map> batches = new HashMap<>(); + + for (int i = 0; i < files.size(); i++) { + Map.Entry file = files.get(i); + RouteResult route = routeForPath(file.getKey()); + batches.computeIfAbsent(route.backend(), k -> new ArrayList<>()) + .add(new IndexedFile(i, file.getKey(), route.backendPath(), file.getValue())); + } + + for (Map.Entry> batch : batches.entrySet()) { + List> batchFiles = new ArrayList<>(); + for (IndexedFile f : batch.getValue()) { + batchFiles.add(Map.entry(f.backendPath(), f.content())); + } + List responses = + batch.getKey().uploadFiles(runtimeContext, batchFiles); + List indexed = batch.getValue(); + for (int i = 0; i < responses.size() && i < indexed.size(); i++) { + results[indexed.get(i).originalIndex()] = + FileUploadResponse.success(indexed.get(i).originalPath()); + } + } + + return List.of(results); + } + + @Override + public List downloadFiles( + RuntimeContext runtimeContext, List paths) { + FileDownloadResponse[] results = new FileDownloadResponse[paths.size()]; + Map> batches = new HashMap<>(); + Map> batchPaths = new HashMap<>(); + + for (int i = 0; i < paths.size(); i++) { + RouteResult route = routeForPath(paths.get(i)); + batches.computeIfAbsent(route.backend(), k -> new ArrayList<>()).add(new int[] {i}); + batchPaths + .computeIfAbsent(route.backend(), k -> new ArrayList<>()) + .add(route.backendPath()); + } + + for (Map.Entry> batch : batchPaths.entrySet()) { + List responses = + batch.getKey().downloadFiles(runtimeContext, batch.getValue()); + List indices = batches.get(batch.getKey()); + for (int i = 0; i < responses.size() && i < indices.size(); i++) { + FileDownloadResponse resp = responses.get(i); + int origIdx = indices.get(i)[0]; + results[origIdx] = + resp.error() != null + ? FileDownloadResponse.fail(paths.get(origIdx), resp.error()) + : FileDownloadResponse.success(paths.get(origIdx), resp.content()); + } + } + + return List.of(results); + } + + @Override + public WriteResult delete(RuntimeContext runtimeContext, String path) { + AbstractFilesystem.validatePath(path); + RouteResult route = routeForPath(path); + WriteResult result = route.backend().delete(runtimeContext, route.backendPath()); + if (result.isSuccess() && route.routePrefix() != null) { + return WriteResult.ok(path); + } + return result; + } + + @Override + public WriteResult move(RuntimeContext runtimeContext, String fromPath, String toPath) { + AbstractFilesystem.validatePath(fromPath); + AbstractFilesystem.validatePath(toPath); + RouteResult srcRoute = routeForPath(fromPath); + RouteResult dstRoute = routeForPath(toPath); + + if (srcRoute.backend() == dstRoute.backend()) { + WriteResult result = + srcRoute.backend() + .move(runtimeContext, srcRoute.backendPath(), dstRoute.backendPath()); + if (result.isSuccess()) { + return WriteResult.ok(toPath); + } + return result; + } + + // Cross-backend move: read → write → delete + var readResult = srcRoute.backend().read(runtimeContext, srcRoute.backendPath(), 0, 0); + if (!readResult.isSuccess() || readResult.fileData() == null) { + return WriteResult.fail("Cannot read source for cross-backend move: " + fromPath); + } + String content = readResult.fileData().content(); + if (content == null) { + content = ""; + } + WriteResult writeResult = + dstRoute.backend().write(runtimeContext, dstRoute.backendPath(), content); + if (!writeResult.isSuccess()) { + return WriteResult.fail( + "Cross-backend move write failed for '" + toPath + "': " + writeResult.error()); + } + srcRoute.backend().delete(runtimeContext, srcRoute.backendPath()); + return WriteResult.ok(toPath); + } + + @Override + public boolean exists(RuntimeContext runtimeContext, String path) { + if (path == null || path.isBlank()) { + return false; + } + RouteResult route = routeForPath(path); + return route.backend().exists(runtimeContext, route.backendPath()); + } + + /** Returns the default backend. */ + public AbstractFilesystem getDefaultBackend() { + return defaultBackend; + } + + private record IndexedFile( + int originalIndex, String originalPath, String backendPath, byte[] content) {} +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/LocalFilesystem.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/LocalFilesystem.java new file mode 100644 index 000000000..3df13e4cf --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/LocalFilesystem.java @@ -0,0 +1,715 @@ +/* + * 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.filesystem; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.filesystem.model.EditResult; +import io.agentscope.harness.agent.filesystem.model.FileData; +import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse; +import io.agentscope.harness.agent.filesystem.model.FileInfo; +import io.agentscope.harness.agent.filesystem.model.FileUploadResponse; +import io.agentscope.harness.agent.filesystem.model.GlobResult; +import io.agentscope.harness.agent.filesystem.model.GrepMatch; +import io.agentscope.harness.agent.filesystem.model.GrepResult; +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.filesystem.util.FilesystemUtils; +import io.agentscope.harness.agent.store.NamespaceFactory; +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.nio.file.DirectoryStream; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.FileVisitResult; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.PathMatcher; +import java.nio.file.SimpleFileVisitor; +import java.nio.file.StandardCopyOption; +import java.nio.file.attribute.BasicFileAttributes; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link AbstractFilesystem} implementation that reads and writes files on the local disk. + * + *

    When {@code virtualMode} is enabled, paths are anchored to {@code rootDir} and traversal is + * blocked. + */ +public class LocalFilesystem implements AbstractFilesystem { + + private static final Logger log = LoggerFactory.getLogger(LocalFilesystem.class); + + private static final int DEFAULT_MAX_FILE_SIZE_MB = 10; + + private final Path cwd; + private final boolean virtualMode; + private final long maxFileSizeBytes; + private final NamespaceFactory namespaceFactory; + + /** + * Same as {@link #LocalFilesystem(Path)} with {@link Path#of(String, String...) Path.of(path)} + * after {@link String#strip()}. Pass {@code null} for the same CWD semantics as a {@code null} + * {@link Path}. Blank strings are rejected. + * + * @param rootDir filesystem root as a path string, or {@code null} for process working directory + */ + public LocalFilesystem(String rootDir) { + this(rootDirFromString(rootDir), false, DEFAULT_MAX_FILE_SIZE_MB, null); + } + + /** + * Creates a abstract filesystem rooted at the given directory. + * + * @param rootDir root directory for all operations ({@code null} means CWD) + */ + public LocalFilesystem(Path rootDir) { + this(rootDir, false, DEFAULT_MAX_FILE_SIZE_MB, null); + } + + /** + * Creates a abstract filesystem with explicit configuration. + * + * @param rootDir root directory for all operations ({@code null} means CWD) + * @param virtualMode when true, all paths are anchored to rootDir and traversal is blocked + * @param maxFileSizeMb maximum file size in megabytes for search operations + */ + public LocalFilesystem(Path rootDir, boolean virtualMode, int maxFileSizeMb) { + this(rootDir, virtualMode, maxFileSizeMb, null); + } + + /** + * Same as {@link #LocalFilesystem(Path, boolean, int)} with a path string; see + * {@link #LocalFilesystem(String)} for {@code null} / blank rules. + */ + public LocalFilesystem(String rootDir, boolean virtualMode, int maxFileSizeMb) { + this(rootDirFromString(rootDir), virtualMode, maxFileSizeMb, null); + } + + /** + * Creates a abstract filesystem with explicit configuration and namespace support. + * + *

    When a {@link NamespaceFactory} is provided, all paths are prefixed with the + * namespace segments joined as subdirectories. For example, with namespace {@code ["user123"]}, + * a read of {@code "MEMORY.md"} resolves to {@code {rootDir}/user123/MEMORY.md}. + * + * @param rootDir root directory for all operations ({@code null} means CWD) + * @param virtualMode when true, all paths are anchored to rootDir and traversal is blocked + * @param maxFileSizeMb maximum file size in megabytes for search operations + * @param namespaceFactory optional namespace factory for path scoping ({@code null} for none) + */ + public LocalFilesystem( + Path rootDir, + boolean virtualMode, + int maxFileSizeMb, + NamespaceFactory namespaceFactory) { + this.cwd = + rootDir != null + ? rootDir.toAbsolutePath().normalize() + : Path.of("").toAbsolutePath(); + this.virtualMode = virtualMode; + this.maxFileSizeBytes = (long) maxFileSizeMb * 1024 * 1024; + this.namespaceFactory = namespaceFactory; + } + + /** + * Same as {@link #LocalFilesystem(Path, boolean, int, NamespaceFactory)} with a path string; + * see {@link #LocalFilesystem(String)} for {@code null} / blank rules. + */ + public LocalFilesystem( + String rootDir, + boolean virtualMode, + int maxFileSizeMb, + NamespaceFactory namespaceFactory) { + this(rootDirFromString(rootDir), virtualMode, maxFileSizeMb, namespaceFactory); + } + + /** + * Converts a root path string to {@link Path}. {@code null} yields {@code null} (CWD). Non-null + * values must be non-blank after {@link String#strip()}. + */ + static Path rootDirFromString(String rootDir) { + if (rootDir == null) { + return null; + } + String trimmed = rootDir.strip(); + if (trimmed.isEmpty()) { + throw new IllegalArgumentException("root directory path must not be blank"); + } + return Path.of(trimmed); + } + + /** + * Returns the root directory for this filesystem. + */ + public Path getCwd() { + return cwd; + } + + @Override + public LsResult ls(RuntimeContext runtimeContext, String path) { + Path dirPath = resolvePath(path); + if (!Files.exists(dirPath) || !Files.isDirectory(dirPath)) { + return LsResult.success(List.of()); + } + + List results = new ArrayList<>(); + try (DirectoryStream ds = Files.newDirectoryStream(dirPath)) { + for (Path entry : ds) { + try { + BasicFileAttributes attrs = + Files.readAttributes(entry, BasicFileAttributes.class); + String entryPath = + virtualMode ? toVirtualPath(entry) : entry.toAbsolutePath().toString(); + String modifiedAt = + Instant.ofEpochMilli(attrs.lastModifiedTime().toMillis()).toString(); + + if (attrs.isDirectory()) { + results.add(FileInfo.ofDir(entryPath + "/", modifiedAt)); + } else { + results.add(FileInfo.ofFile(entryPath, attrs.size(), modifiedAt)); + } + } catch (IOException e) { + log.debug("Skipping unreadable entry: {}", entry); + } + } + } catch (IOException e) { + log.warn("ls failed for {}: {}", path, e.getMessage()); + } + + results.sort(Comparator.comparing(FileInfo::path)); + return LsResult.success(results); + } + + @Override + public ReadResult read(RuntimeContext runtimeContext, String filePath, int offset, int limit) { + Path resolved = resolvePath(filePath); + + if (!Files.exists(resolved) || !Files.isRegularFile(resolved)) { + return ReadResult.fail("File '" + filePath + "' not found"); + } + + try { + if (!"text".equals(FilesystemUtils.getFileType(filePath))) { + byte[] raw = Files.readAllBytes(resolved); + String encoded = Base64.getEncoder().encodeToString(raw); + return ReadResult.success(new FileData(encoded, "base64")); + } + + String content = Files.readString(resolved, StandardCharsets.UTF_8); + + if (content.isEmpty() || content.isBlank()) { + return ReadResult.success( + new FileData( + "System reminder: File exists but has empty contents", "utf-8")); + } + + String[] lines = content.split("\n", -1); + int startIdx = Math.max(0, offset); + int endIdx = limit > 0 ? Math.min(startIdx + limit, lines.length) : lines.length; + + if (startIdx >= lines.length) { + return ReadResult.fail( + "Line offset " + + offset + + " exceeds file length (" + + lines.length + + " lines)"); + } + + StringBuilder sb = new StringBuilder(); + for (int i = startIdx; i < endIdx; i++) { + if (i > startIdx) { + sb.append('\n'); + } + sb.append(lines[i]); + } + return ReadResult.success(new FileData(sb.toString(), "utf-8")); + + } catch (IOException e) { + return ReadResult.fail("Error reading file '" + filePath + "': " + e.getMessage()); + } + } + + @Override + public WriteResult write(RuntimeContext runtimeContext, String filePath, String content) { + Path resolved = resolvePath(filePath); + + if (Files.exists(resolved)) { + return WriteResult.fail( + "Cannot write to " + + filePath + + " because it already exists. Read and then make an edit," + + " or write to a new path."); + } + + try { + if (resolved.getParent() != null) { + Files.createDirectories(resolved.getParent()); + } + Files.writeString(resolved, content, StandardCharsets.UTF_8); + return WriteResult.ok(filePath); + } catch (IOException e) { + return WriteResult.fail("Error writing file '" + filePath + "': " + e.getMessage()); + } + } + + @Override + public EditResult edit( + RuntimeContext runtimeContext, + String filePath, + String oldString, + String newString, + boolean replaceAll) { + Path resolved = resolvePath(filePath); + + if (!Files.exists(resolved) || !Files.isRegularFile(resolved)) { + return EditResult.fail("Error: File '" + filePath + "' not found"); + } + + try { + String content = Files.readString(resolved, StandardCharsets.UTF_8); + String normalizedOld = oldString.replace("\r\n", "\n").replace("\r", "\n"); + String normalizedNew = newString.replace("\r\n", "\n").replace("\r", "\n"); + + Object[] result = + FilesystemUtils.performStringReplacement( + content, normalizedOld, normalizedNew, replaceAll); + + if (result.length == 1) { + return EditResult.fail((String) result[0]); + } + + String newContent = (String) result[0]; + int occurrences = (int) result[1]; + + Files.writeString(resolved, newContent, StandardCharsets.UTF_8); + return EditResult.ok(filePath, occurrences); + } catch (IOException e) { + return EditResult.fail("Error editing file '" + filePath + "': " + e.getMessage()); + } + } + + @Override + public GrepResult grep( + RuntimeContext runtimeContext, String pattern, String path, String glob) { + Path basePath; + try { + basePath = resolvePath(path != null ? path : "."); + } catch (SecurityException e) { + return GrepResult.success(List.of()); + } + + if (!Files.exists(basePath)) { + return GrepResult.success(List.of()); + } + + List matches = ripgrepSearch(pattern, basePath, glob); + if (matches == null) { + matches = javaSearch(pattern, basePath, glob); + } + return GrepResult.success(matches); + } + + @Override + public GlobResult glob(RuntimeContext runtimeContext, String pattern, String path) { + String effectivePattern = pattern; + if (effectivePattern.startsWith("/")) { + effectivePattern = effectivePattern.substring(1); + } + + Path searchPath; + if ("/".equals(path) || path == null) { + searchPath = cwd; + } else { + searchPath = resolvePath(path); + } + + if (!Files.exists(searchPath) || !Files.isDirectory(searchPath)) { + return GlobResult.success(List.of()); + } + + String globExpr = + effectivePattern.startsWith("**") ? effectivePattern : "**/" + effectivePattern; + FileSystem fs = FileSystems.getDefault(); + PathMatcher matcher = fs.getPathMatcher("glob:" + globExpr); + + List results = new ArrayList<>(); + try { + Files.walkFileTree( + searchPath, + new SimpleFileVisitor<>() { + @Override + public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) { + Path rel = searchPath.relativize(file); + if (matcher.matches(rel)) { + String filePath = + virtualMode + ? toVirtualPath(file) + : file.toAbsolutePath().toString(); + String modifiedAt = + Instant.ofEpochMilli(attrs.lastModifiedTime().toMillis()) + .toString(); + results.add(FileInfo.ofFile(filePath, attrs.size(), modifiedAt)); + } + return FileVisitResult.CONTINUE; + } + + @Override + public FileVisitResult visitFileFailed(Path file, IOException exc) { + return FileVisitResult.CONTINUE; + } + }); + } catch (IOException e) { + log.warn("glob failed for {}: {}", pattern, e.getMessage()); + } + + results.sort(Comparator.comparing(FileInfo::path)); + return GlobResult.success(results); + } + + @Override + public List uploadFiles( + RuntimeContext runtimeContext, List> files) { + List responses = new ArrayList<>(); + for (Map.Entry entry : files) { + String filePath = entry.getKey(); + byte[] content = entry.getValue(); + try { + Path resolved = resolvePath(filePath); + if (resolved.getParent() != null) { + Files.createDirectories(resolved.getParent()); + } + Files.write(resolved, content); + responses.add(FileUploadResponse.success(filePath)); + } catch (IOException e) { + responses.add(FileUploadResponse.fail(filePath, e.getMessage())); + } catch (SecurityException e) { + responses.add(FileUploadResponse.fail(filePath, "permission_denied")); + } + } + return responses; + } + + @Override + public List downloadFiles( + RuntimeContext runtimeContext, List paths) { + List responses = new ArrayList<>(); + for (String filePath : paths) { + try { + Path resolved = resolvePath(filePath); + if (!Files.exists(resolved)) { + responses.add(FileDownloadResponse.fail(filePath, "file_not_found")); + continue; + } + if (Files.isDirectory(resolved)) { + responses.add(FileDownloadResponse.fail(filePath, "is_directory")); + continue; + } + byte[] content = Files.readAllBytes(resolved); + responses.add(FileDownloadResponse.success(filePath, content)); + } catch (IOException e) { + responses.add(FileDownloadResponse.fail(filePath, e.getMessage())); + } catch (SecurityException e) { + responses.add(FileDownloadResponse.fail(filePath, "permission_denied")); + } + } + return responses; + } + + @Override + public WriteResult delete(RuntimeContext runtimeContext, String path) { + AbstractFilesystem.validatePath(path); + Path resolved = resolvePath(path); + if (!Files.exists(resolved)) { + return WriteResult.ok(path); // idempotent + } + try { + if (Files.isDirectory(resolved)) { + try (Stream walk = Files.walk(resolved)) { + walk.sorted(Comparator.reverseOrder()) + .forEach( + p -> { + try { + Files.delete(p); + } catch (IOException e) { + log.warn("Failed to delete {}: {}", p, e.getMessage()); + } + }); + } + } else { + Files.delete(resolved); + } + return WriteResult.ok(path); + } catch (IOException e) { + return WriteResult.fail("Error deleting '" + path + "': " + e.getMessage()); + } + } + + @Override + public WriteResult move(RuntimeContext runtimeContext, String fromPath, String toPath) { + AbstractFilesystem.validatePath(fromPath); + AbstractFilesystem.validatePath(toPath); + Path from = resolvePath(fromPath); + Path to = resolvePath(toPath); + if (!Files.exists(from)) { + return WriteResult.fail("Source does not exist: " + fromPath); + } + try { + if (to.getParent() != null) { + Files.createDirectories(to.getParent()); + } + Files.move(from, to, StandardCopyOption.REPLACE_EXISTING); + return WriteResult.ok(toPath); + } catch (IOException e) { + return WriteResult.fail( + "Error moving '" + fromPath + "' to '" + toPath + "': " + e.getMessage()); + } + } + + @Override + public boolean exists(RuntimeContext runtimeContext, String path) { + if (path == null || path.isBlank()) { + return false; + } + try { + return Files.exists(resolvePath(path)); + } catch (SecurityException e) { + return false; + } + } + + // ==================== Path resolution ==================== + + protected NamespaceFactory getNamespaceFactory() { + return namespaceFactory; + } + + protected Path resolvePath(String key) { + String effectiveKey = applyNamespacePrefix(key); + if (effectiveKey == null || effectiveKey.isBlank()) { + return cwd; + } + + if (virtualMode) { + String vpath = effectiveKey.startsWith("/") ? effectiveKey : "/" + effectiveKey; + if (vpath.contains("..") || vpath.startsWith("~")) { + throw new SecurityException("Path traversal not allowed"); + } + Path full = cwd.resolve(vpath.substring(1)).normalize(); + if (!full.startsWith(cwd)) { + throw new SecurityException("Path " + full + " outside root directory: " + cwd); + } + return full; + } + + Path target = Path.of(effectiveKey); + if (target.isAbsolute()) { + return target; + } + return cwd.resolve(target).normalize(); + } + + private String applyNamespacePrefix(String key) { + if (namespaceFactory == null || key == null || key.isBlank()) { + return key; + } + List ns = namespaceFactory.getNamespace(); + if (ns == null || ns.isEmpty()) { + return key; + } + String prefix = String.join("/", ns); + return prefix + "/" + key; + } + + protected String toVirtualPath(Path path) { + return "/" + + path.toAbsolutePath() + .normalize() + .toString() + .substring(cwd.toString().length()) + .replace('\\', '/') + .replaceFirst("^/+", ""); + } + + // ==================== Grep implementations ==================== + + private List ripgrepSearch(String pattern, Path basePath, String includeGlob) { + List cmd = new ArrayList<>(); + cmd.add("rg"); + cmd.add("--json"); + cmd.add("-F"); + if (includeGlob != null && !includeGlob.isBlank()) { + cmd.add("--glob"); + cmd.add(includeGlob); + } + cmd.add("--"); + cmd.add(pattern); + cmd.add(basePath.toString()); + + try { + ProcessBuilder pb = new ProcessBuilder(cmd); + pb.redirectErrorStream(true); + Process proc = pb.start(); + + List matches = new ArrayList<>(); + try (BufferedReader reader = + new BufferedReader( + new InputStreamReader(proc.getInputStream(), StandardCharsets.UTF_8))) { + String line; + while ((line = reader.readLine()) != null) { + GrepMatch match = parseRipgrepJsonLine(line); + if (match != null) { + matches.add(match); + } + } + } + + proc.waitFor(); + return matches; + + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + return null; + } + } + + private GrepMatch parseRipgrepJsonLine(String jsonLine) { + try { + if (!jsonLine.contains("\"type\":\"match\"")) { + return null; + } + String pathText = extractJsonStringField(jsonLine, "text", "path"); + String lineNumStr = extractJsonField(jsonLine, "line_number"); + String linesText = extractJsonStringField(jsonLine, "text", "lines"); + + if (pathText == null || lineNumStr == null) { + return null; + } + String filePath = virtualMode ? toVirtualPath(Path.of(pathText)) : pathText; + int lineNum = Integer.parseInt(lineNumStr.trim()); + String text = linesText != null ? linesText.replaceAll("[\r\n]+$", "") : ""; + return new GrepMatch(filePath, lineNum, text); + } catch (Exception e) { + return null; + } + } + + private static String extractJsonStringField(String json, String field, String parentField) { + String searchKey = "\"" + parentField + "\":{"; + int parentIdx = json.indexOf(searchKey); + if (parentIdx < 0) { + return extractSimpleJsonString(json, field); + } + String sub = json.substring(parentIdx + searchKey.length()); + return extractSimpleJsonString(sub, field); + } + + private static String extractSimpleJsonString(String json, String field) { + String key = "\"" + field + "\":\""; + int start = json.indexOf(key); + if (start < 0) { + return null; + } + start += key.length(); + int end = json.indexOf('"', start); + if (end < 0) { + return null; + } + return json.substring(start, end); + } + + private static String extractJsonField(String json, String field) { + String key = "\"" + field + "\":"; + int start = json.indexOf(key); + if (start < 0) { + return null; + } + start += key.length(); + int end = start; + while (end < json.length() && json.charAt(end) != ',' && json.charAt(end) != '}') { + end++; + } + return json.substring(start, end).trim(); + } + + private List javaSearch(String pattern, Path basePath, String includeGlob) { + Pattern compiledPattern = Pattern.compile(Pattern.quote(pattern)); + PathMatcher globMatcher = null; + if (includeGlob != null && !includeGlob.isBlank()) { + globMatcher = FileSystems.getDefault().getPathMatcher("glob:" + includeGlob); + } + + List matches = new ArrayList<>(); + Path root = Files.isDirectory(basePath) ? basePath : basePath.getParent(); + + try (Stream walk = Files.walk(root)) { + PathMatcher finalGlobMatcher = globMatcher; + walk.filter(Files::isRegularFile) + .filter( + p -> { + if (finalGlobMatcher != null) { + return finalGlobMatcher.matches(p.getFileName()); + } + return true; + }) + .filter( + p -> { + try { + return Files.size(p) <= maxFileSizeBytes; + } catch (IOException e) { + return false; + } + }) + .forEach( + file -> { + try { + List lines = + Files.readAllLines(file, StandardCharsets.UTF_8); + for (int i = 0; i < lines.size(); i++) { + if (compiledPattern.matcher(lines.get(i)).find()) { + String filePath = + virtualMode + ? toVirtualPath(file) + : file.toAbsolutePath().toString(); + matches.add( + new GrepMatch(filePath, i + 1, lines.get(i))); + } + } + } catch (IOException e) { + // Skip binary/unreadable files + } + }); + } catch (IOException e) { + log.warn("Java search failed: {}", e.getMessage()); + } + + return matches; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/LocalFilesystemSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/LocalFilesystemSpec.java new file mode 100644 index 000000000..3501f8194 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/LocalFilesystemSpec.java @@ -0,0 +1,128 @@ +/* + * 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.filesystem; + +import io.agentscope.harness.agent.store.NamespaceFactory; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Specification for the local filesystem mode (with shell execution). + * + *

    This spec produces a {@link LocalFilesystemWithShell} whose root is the agent workspace and + * whose shell runs directly on the host as {@code sh -c }. Long-term memory + * ({@code MEMORY.md}, {@code memory/}) and session logs live on the same local disk. + * + *

    Suitable for single-process deployments (personal assistants, CLI tools, local dev loops) + * where distributed sharing is not required and the agent is trusted to run host shell commands. + * + *

    For distributed deployments where long-term memory must be shared across replicas, prefer + * {@link RemoteFilesystemSpec} (no shell) or a sandbox filesystem spec (shell via sandbox). + */ +public class LocalFilesystemSpec { + + private int executeTimeoutSeconds = LocalFilesystemWithShell.DEFAULT_EXECUTE_TIMEOUT; + private int maxOutputBytes = 100_000; + private final Map env = new LinkedHashMap<>(); + private boolean inheritEnv = false; + private boolean virtualMode = false; + + /** + * Sets the default command execution timeout in seconds. + * + * @param seconds timeout (must be positive) + * @return this spec + */ + public LocalFilesystemSpec executeTimeoutSeconds(int seconds) { + if (seconds <= 0) { + throw new IllegalArgumentException("timeout must be positive, got " + seconds); + } + this.executeTimeoutSeconds = seconds; + return this; + } + + /** + * Sets the maximum number of output bytes captured from any single shell command. + * + * @param bytes byte cap (must be positive) + * @return this spec + */ + public LocalFilesystemSpec maxOutputBytes(int bytes) { + if (bytes <= 0) { + throw new IllegalArgumentException("maxOutputBytes must be positive, got " + bytes); + } + this.maxOutputBytes = bytes; + return this; + } + + /** + * Adds an environment variable that will be set for every shell command. + * + * @param name variable name + * @param value variable value + * @return this spec + */ + public LocalFilesystemSpec env(String name, String value) { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("env name must not be blank"); + } + this.env.put(name, value); + return this; + } + + /** + * Controls whether the parent process environment is inherited by shell commands. When + * {@code false} (default), only variables added via {@link #env(String, String)} are visible. + * + * @param inherit whether to inherit parent env + * @return this spec + */ + public LocalFilesystemSpec inheritEnv(boolean inherit) { + this.inheritEnv = inherit; + return this; + } + + /** + * Enables virtual-path mode: paths are anchored to the workspace root and traversal outside + * is blocked. + * + * @param virtual whether to enable virtual mode + * @return this spec + */ + public LocalFilesystemSpec virtualMode(boolean virtual) { + this.virtualMode = virtual; + return this; + } + + /** + * Builds the effective filesystem rooted at {@code workspace}. + * + * @param workspace agent workspace root + * @param localNamespaceFactory optional namespace factory for per-user/session folder scoping + * @return a {@link LocalFilesystemWithShell} wired with the options in this spec + */ + public AbstractFilesystem toFilesystem(Path workspace, NamespaceFactory localNamespaceFactory) { + return new LocalFilesystemWithShell( + workspace, + virtualMode, + executeTimeoutSeconds, + maxOutputBytes, + env.isEmpty() ? null : Map.copyOf(env), + inheritEnv, + localNamespaceFactory); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/LocalFilesystemWithShell.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/LocalFilesystemWithShell.java new file mode 100644 index 000000000..a70a1e80f --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/LocalFilesystemWithShell.java @@ -0,0 +1,331 @@ +/* + * 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.filesystem; + +import io.agentscope.harness.agent.filesystem.model.ExecuteResponse; +import io.agentscope.harness.agent.store.NamespaceFactory; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Filesystem with unrestricted local shell command execution. + * + *

    This implementation extends {@link LocalFilesystem} to add shell command execution + * capabilities. Commands are executed directly on the host system without any + * sandboxing, process isolation, or security restrictions. + * + *

    WARNING: This implementation grants agents BOTH direct filesystem access AND unrestricted + * shell execution on your local machine. Use with extreme caution and only in + * appropriate environments (local dev, CI/CD with proper secret management). + */ +public class LocalFilesystemWithShell extends LocalFilesystem implements AbstractSandboxFilesystem { + + private static final Logger log = LoggerFactory.getLogger(LocalFilesystemWithShell.class); + + /** Default timeout in seconds for shell command execution. */ + public static final int DEFAULT_EXECUTE_TIMEOUT = 120; + + private final String sandboxId; + private final int defaultTimeout; + private final int maxOutputBytes; + private final Map env; + + /** + * Creates an abstract filesystem with default settings. + * + * @param rootDir working directory for both filesystem and shell operations + */ + public LocalFilesystemWithShell(Path rootDir) { + this(rootDir, false, DEFAULT_EXECUTE_TIMEOUT, 100_000, null, false, null); + } + + /** + * Same as {@link #LocalFilesystemWithShell(Path)} with a path string; see + * {@link LocalFilesystem#LocalFilesystem(String)} for {@code null} / blank rules. + */ + public LocalFilesystemWithShell(String rootDir) { + this( + LocalFilesystem.rootDirFromString(rootDir), + false, + DEFAULT_EXECUTE_TIMEOUT, + 100_000, + null, + false, + null); + } + + /** + * Creates an abstract filesystem with default settings and namespace support. + * + * @param rootDir working directory for both filesystem and shell operations + * @param namespaceFactory optional namespace factory for path scoping ({@code null} for none) + */ + public LocalFilesystemWithShell(Path rootDir, NamespaceFactory namespaceFactory) { + this(rootDir, false, DEFAULT_EXECUTE_TIMEOUT, 100_000, null, false, namespaceFactory); + } + + /** + * Same as {@link #LocalFilesystemWithShell(Path, NamespaceFactory)} with a path string; see + * {@link LocalFilesystem#LocalFilesystem(String)} for {@code null} / blank rules. + */ + public LocalFilesystemWithShell(String rootDir, NamespaceFactory namespaceFactory) { + this( + LocalFilesystem.rootDirFromString(rootDir), + false, + DEFAULT_EXECUTE_TIMEOUT, + 100_000, + null, + false, + namespaceFactory); + } + + /** + * Creates a abstract filesystem with full configuration. + * + * @param rootDir working directory for both filesystem and shell operations + * @param virtualMode enable virtual path mode for filesystem operations + * @param timeout default maximum time in seconds for shell command execution + * @param maxOutputBytes maximum number of bytes to capture from command output + * @param env environment variables for shell commands ({@code null} for empty) + * @param inheritEnv whether to inherit the parent process's environment variables + */ + public LocalFilesystemWithShell( + Path rootDir, + boolean virtualMode, + int timeout, + int maxOutputBytes, + Map env, + boolean inheritEnv) { + this(rootDir, virtualMode, timeout, maxOutputBytes, env, inheritEnv, null); + } + + /** + * Same as {@link #LocalFilesystemWithShell(Path, boolean, int, int, Map, boolean)} with a path + * string; see {@link LocalFilesystem#LocalFilesystem(String)} for {@code null} / blank rules. + */ + public LocalFilesystemWithShell( + String rootDir, + boolean virtualMode, + int timeout, + int maxOutputBytes, + Map env, + boolean inheritEnv) { + this( + LocalFilesystem.rootDirFromString(rootDir), + virtualMode, + timeout, + maxOutputBytes, + env, + inheritEnv, + null); + } + + /** + * Creates a abstract filesystem with full configuration and namespace support. + * + * @param rootDir working directory for both filesystem and shell operations + * @param virtualMode enable virtual path mode for filesystem operations + * @param timeout default maximum time in seconds for shell command execution + * @param maxOutputBytes maximum number of bytes to capture from command output + * @param env environment variables for shell commands ({@code null} for empty) + * @param inheritEnv whether to inherit the parent process's environment variables + * @param namespaceFactory optional namespace factory for path scoping ({@code null} for none) + */ + public LocalFilesystemWithShell( + Path rootDir, + boolean virtualMode, + int timeout, + int maxOutputBytes, + Map env, + boolean inheritEnv, + NamespaceFactory namespaceFactory) { + super(rootDir, virtualMode, 10, namespaceFactory); + + if (timeout <= 0) { + throw new IllegalArgumentException("timeout must be positive, got " + timeout); + } + + this.defaultTimeout = timeout; + this.maxOutputBytes = maxOutputBytes; + this.sandboxId = "local-" + UUID.randomUUID().toString().substring(0, 8); + + if (inheritEnv) { + Map merged = new java.util.HashMap<>(System.getenv()); + if (env != null) { + merged.putAll(env); + } + this.env = Map.copyOf(merged); + } else { + this.env = env != null ? Map.copyOf(env) : Map.of(); + } + } + + /** + * Same as {@link #LocalFilesystemWithShell(Path, boolean, int, int, Map, boolean, + * NamespaceFactory)} with a path string; see {@link LocalFilesystem#LocalFilesystem(String)} + * for {@code null} / blank rules. + */ + public LocalFilesystemWithShell( + String rootDir, + boolean virtualMode, + int timeout, + int maxOutputBytes, + Map env, + boolean inheritEnv, + NamespaceFactory namespaceFactory) { + this( + LocalFilesystem.rootDirFromString(rootDir), + virtualMode, + timeout, + maxOutputBytes, + env, + inheritEnv, + namespaceFactory); + } + + @Override + public String id() { + return sandboxId; + } + + @Override + public ExecuteResponse execute(String command, Integer timeoutSeconds) { + if (command == null || command.isBlank()) { + return new ExecuteResponse("Error: Command must be a non-empty string.", 1, false); + } + + int effectiveTimeout = timeoutSeconds != null ? timeoutSeconds : defaultTimeout; + if (effectiveTimeout <= 0) { + throw new IllegalArgumentException("timeout must be positive, got " + effectiveTimeout); + } + + try { + Path workDir = resolveExecuteCwd(); + ProcessBuilder pb = + new ProcessBuilder("sh", "-c", command) + .directory(workDir.toFile()) + .redirectErrorStream(false); + + if (!env.isEmpty()) { + pb.environment().clear(); + pb.environment().putAll(env); + } + + Process proc = pb.start(); + + boolean finished = proc.waitFor(effectiveTimeout, TimeUnit.SECONDS); + + String stdout = + new String(proc.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + String stderr = + new String(proc.getErrorStream().readAllBytes(), StandardCharsets.UTF_8); + + if (!finished) { + proc.destroyForcibly(); + String msg; + if (timeoutSeconds != null) { + msg = + "Error: Command timed out after " + + effectiveTimeout + + " seconds (custom timeout). The command may be stuck or" + + " require more time."; + } else { + msg = + "Error: Command timed out after " + + effectiveTimeout + + " seconds. For long-running commands, re-run using the" + + " timeout parameter."; + } + return new ExecuteResponse(msg, 124, false); + } + + StringBuilder output = new StringBuilder(); + if (stdout != null && !stdout.isEmpty()) { + output.append(stdout); + } + if (stderr != null && !stderr.isBlank()) { + String[] stderrLines = stderr.strip().split("\n"); + for (String line : stderrLines) { + if (!output.isEmpty()) { + output.append('\n'); + } + output.append("[stderr] ").append(line); + } + } + + String outputStr = output.isEmpty() ? "" : output.toString(); + + boolean truncated = false; + if (outputStr.length() > maxOutputBytes) { + outputStr = + outputStr.substring(0, maxOutputBytes) + + "\n\n... Output truncated at " + + maxOutputBytes + + " bytes."; + truncated = true; + } + + int exitCode = proc.exitValue(); + if (exitCode != 0) { + outputStr = outputStr.stripTrailing() + "\n\nExit code: " + exitCode; + } + + return new ExecuteResponse(outputStr, exitCode, truncated); + + } catch (IOException | InterruptedException e) { + if (e instanceof InterruptedException) { + Thread.currentThread().interrupt(); + } + log.error("Command execution failed: {}", e.getMessage(), e); + return new ExecuteResponse( + "Error executing command (" + + e.getClass().getSimpleName() + + "): " + + e.getMessage(), + 1, + false); + } + } + + private Path resolveExecuteCwd() { + NamespaceFactory nsf = getNamespaceFactory(); + if (nsf == null) { + return getCwd(); + } + List ns = nsf.getNamespace(); + if (ns == null || ns.isEmpty()) { + return getCwd(); + } + Path namespaced = getCwd(); + for (String segment : ns) { + namespaced = namespaced.resolve(segment); + } + try { + Files.createDirectories(namespaced); + } catch (IOException e) { + log.warn("Failed to create namespace directory {}: {}", namespaced, e.getMessage()); + } + return namespaced; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/RemoteFilesystem.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/RemoteFilesystem.java new file mode 100644 index 000000000..42d93248e --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/RemoteFilesystem.java @@ -0,0 +1,539 @@ +/* + * 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.filesystem; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.filesystem.model.EditResult; +import io.agentscope.harness.agent.filesystem.model.FileData; +import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse; +import io.agentscope.harness.agent.filesystem.model.FileInfo; +import io.agentscope.harness.agent.filesystem.model.FileUploadResponse; +import io.agentscope.harness.agent.filesystem.model.GlobResult; +import io.agentscope.harness.agent.filesystem.model.GrepMatch; +import io.agentscope.harness.agent.filesystem.model.GrepResult; +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.filesystem.util.FilesystemUtils; +import io.agentscope.harness.agent.store.BaseStore; +import io.agentscope.harness.agent.store.NamespaceFactory; +import io.agentscope.harness.agent.store.StoreItem; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; +import java.nio.file.PathMatcher; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Comparator; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +/** + * {@link AbstractFilesystem} backed by a {@link BaseStore} (persistent, cross-thread). + * + *

    Files are organized via namespaces and persist across threads/sessions. The namespace can be + * static (fixed at construction time) or dynamic (resolved at every operation via a {@link + * NamespaceFactory}). + * + *

    Dynamic namespace example: + * + *

    {@code
    + * RemoteFilesystem fs = new RemoteFilesystem(store,
    + *     () -> List.of("sessions", sessionIdSupplier.get(), "filesystem"));
    + * }
    + */ +public class RemoteFilesystem implements AbstractFilesystem { + + private final BaseStore store; + private final NamespaceFactory namespaceFactory; + + /** + * Creates a RemoteFilesystem with a {@link NamespaceFactory} that is called on every operation, + * allowing the namespace to vary based on runtime context. + * + * @param store the store to use for persistence + * @param namespaceFactory factory that returns the namespace tuple per operation + */ + public RemoteFilesystem(BaseStore store, NamespaceFactory namespaceFactory) { + if (store == null) { + throw new IllegalArgumentException("store must not be null"); + } + if (namespaceFactory == null) { + throw new IllegalArgumentException("namespaceFactory must not be null"); + } + this.store = store; + this.namespaceFactory = namespaceFactory; + } + + /** + * Creates a RemoteFilesystem with a fixed namespace. + * + * @param store the store to use for persistence + * @param namespace the namespace tuple for organizing files + */ + public RemoteFilesystem(BaseStore store, List namespace) { + this(store, toFactory(namespace)); + } + + /** + * Creates a RemoteFilesystem with a default "filesystem" namespace. + * + * @param store the store to use for persistence + */ + public RemoteFilesystem(BaseStore store) { + this(store, List.of("filesystem")); + } + + private static NamespaceFactory toFactory(List namespace) { + if (namespace == null || namespace.isEmpty()) { + throw new IllegalArgumentException("namespace must not be empty"); + } + List frozen = List.copyOf(namespace); + return () -> frozen; + } + + private List getNamespace() { + List ns = namespaceFactory.getNamespace(); + if (ns == null || ns.isEmpty()) { + throw new IllegalStateException("NamespaceFactory returned null or empty namespace"); + } + return ns; + } + + @Override + public LsResult ls(RuntimeContext runtimeContext, String path) { + List items = searchAllItems(); + List infos = new ArrayList<>(); + Set subdirs = new LinkedHashSet<>(); + + String normalizedPath = path.endsWith("/") ? path : path + "/"; + + for (StoreItem item : items) { + if (!item.key().startsWith(normalizedPath)) { + continue; + } + + String relative = item.key().substring(normalizedPath.length()); + + if (relative.contains("/")) { + String subdirName = relative.substring(0, relative.indexOf('/')); + subdirs.add(normalizedPath + subdirName + "/"); + continue; + } + + FileData fd = convertItemToFileData(item); + if (fd == null) { + continue; + } + int size = fd.content() != null ? fd.content().length() : 0; + infos.add( + FileInfo.ofFile( + item.key(), size, fd.modifiedAt() != null ? fd.modifiedAt() : "")); + } + + for (String subdir : subdirs) { + infos.add(FileInfo.ofDir(subdir, "")); + } + + infos.sort(Comparator.comparing(FileInfo::path)); + return LsResult.success(infos); + } + + @Override + public ReadResult read(RuntimeContext runtimeContext, String filePath, int offset, int limit) { + StoreItem item = store.get(getNamespace(), filePath); + if (item == null) { + return ReadResult.fail("File '" + filePath + "' not found"); + } + + FileData fileData = convertItemToFileData(item); + if (fileData == null) { + return ReadResult.fail("Invalid file data for '" + filePath + "'"); + } + + if (!"text".equals(FilesystemUtils.getFileType(filePath))) { + return ReadResult.success(fileData); + } + + String content = fileData.content(); + if (content == null || content.isBlank()) { + return ReadResult.success(fileData); + } + + String[] lines = content.split("\n", -1); + int startIdx = Math.max(0, offset); + int endIdx = limit > 0 ? Math.min(startIdx + limit, lines.length) : lines.length; + + if (startIdx >= lines.length) { + return ReadResult.fail( + "Line offset " + offset + " exceeds file length (" + lines.length + " lines)"); + } + + StringBuilder sb = new StringBuilder(); + for (int i = startIdx; i < endIdx; i++) { + if (i > startIdx) { + sb.append('\n'); + } + sb.append(lines[i]); + } + + return ReadResult.success( + new FileData( + sb.toString(), + fileData.encoding(), + fileData.createdAt(), + fileData.modifiedAt())); + } + + @Override + public WriteResult write(RuntimeContext runtimeContext, String filePath, String content) { + List ns = getNamespace(); + StoreItem existing = store.get(ns, filePath); + if (existing != null) { + return WriteResult.fail( + "Cannot write to " + + filePath + + " because it already exists. Read and then make an edit," + + " or write to a new path."); + } + + FileData fileData = FileData.create(content); + store.put(ns, filePath, fileDataToStoreValue(fileData)); + return WriteResult.ok(filePath); + } + + @Override + public EditResult edit( + RuntimeContext runtimeContext, + String filePath, + String oldString, + String newString, + boolean replaceAll) { + List ns = getNamespace(); + StoreItem item = store.get(ns, filePath); + if (item == null) { + return EditResult.fail("Error: File '" + filePath + "' not found"); + } + + FileData fileData = convertItemToFileData(item); + if (fileData == null) { + return EditResult.fail("Error: Invalid file data"); + } + + String content = fileData.content() != null ? fileData.content() : ""; + Object[] result = + FilesystemUtils.performStringReplacement(content, oldString, newString, replaceAll); + + if (result.length == 1) { + return EditResult.fail((String) result[0]); + } + + String newContent = (String) result[0]; + int occurrences = (int) result[1]; + + FileData updated = fileData.withContent(newContent); + store.put(ns, filePath, fileDataToStoreValue(updated)); + return EditResult.ok(filePath, occurrences); + } + + @Override + public GrepResult grep( + RuntimeContext runtimeContext, String pattern, String path, String glob) { + List items = searchAllItems(); + String normalizedPath = normalizePath(path); + + PathMatcher globMatcher = null; + if (glob != null && !glob.isBlank()) { + globMatcher = FileSystems.getDefault().getPathMatcher("glob:" + glob); + } + + List matches = new ArrayList<>(); + for (StoreItem item : items) { + String key = item.key(); + + if (!matchesPathPrefix(key, normalizedPath)) { + continue; + } + + if (globMatcher != null) { + String fileName = key.contains("/") ? key.substring(key.lastIndexOf('/') + 1) : key; + if (!globMatcher.matches(java.nio.file.Path.of(fileName))) { + continue; + } + } + + FileData fd = convertItemToFileData(item); + if (fd == null || fd.content() == null) { + continue; + } + + String[] lines = fd.content().split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (lines[i].contains(pattern)) { + matches.add(new GrepMatch(key, i + 1, lines[i])); + } + } + } + + return GrepResult.success(matches); + } + + @Override + public GlobResult glob(RuntimeContext runtimeContext, String pattern, String path) { + List items = searchAllItems(); + String normalizedPath = normalizePath(path); + String effectivePattern = pattern.startsWith("/") ? pattern.substring(1) : pattern; + + PathMatcher matcher = + FileSystems.getDefault() + .getPathMatcher( + "glob:" + + (effectivePattern.startsWith("**") + ? effectivePattern + : "**/" + effectivePattern)); + + List results = new ArrayList<>(); + for (StoreItem item : items) { + String key = item.key(); + if (!matchesPathPrefix(key, normalizedPath)) { + continue; + } + + String relativePath; + if ("/".equals(normalizedPath)) { + relativePath = key.startsWith("/") ? key.substring(1) : key; + } else { + relativePath = key.substring(normalizedPath.length() + 1); + } + + if (matcher.matches(java.nio.file.Path.of(relativePath))) { + FileData fd = convertItemToFileData(item); + int size = (fd != null && fd.content() != null) ? fd.content().length() : 0; + String modifiedAt = (fd != null && fd.modifiedAt() != null) ? fd.modifiedAt() : ""; + results.add(FileInfo.ofFile(key, size, modifiedAt)); + } + } + + results.sort(Comparator.comparing(FileInfo::path)); + return GlobResult.success(results); + } + + @Override + public List uploadFiles( + RuntimeContext runtimeContext, List> files) { + List ns = getNamespace(); + List responses = new ArrayList<>(); + for (Map.Entry entry : files) { + String filePath = entry.getKey(); + byte[] content = entry.getValue(); + + String contentStr; + String encoding; + try { + contentStr = new String(content, StandardCharsets.UTF_8); + encoding = "utf-8"; + } catch (Exception e) { + contentStr = Base64.getEncoder().encodeToString(content); + encoding = "base64"; + } + + FileData fileData = FileData.create(contentStr, encoding); + store.put(ns, filePath, fileDataToStoreValue(fileData)); + responses.add(FileUploadResponse.success(filePath)); + } + return responses; + } + + @Override + public List downloadFiles( + RuntimeContext runtimeContext, List paths) { + List ns = getNamespace(); + List responses = new ArrayList<>(); + for (String filePath : paths) { + StoreItem item = store.get(ns, filePath); + if (item == null) { + responses.add(FileDownloadResponse.fail(filePath, "file_not_found")); + continue; + } + + FileData fd = convertItemToFileData(item); + if (fd == null || fd.content() == null) { + responses.add(FileDownloadResponse.fail(filePath, "invalid file data")); + continue; + } + + byte[] contentBytes; + if ("base64".equals(fd.encoding())) { + contentBytes = Base64.getDecoder().decode(fd.content()); + } else { + contentBytes = fd.content().getBytes(StandardCharsets.UTF_8); + } + responses.add(FileDownloadResponse.success(filePath, contentBytes)); + } + return responses; + } + + @Override + public WriteResult delete(RuntimeContext runtimeContext, String path) { + AbstractFilesystem.validatePath(path); + List ns = getNamespace(); + List items = searchAllItems(); + String normalizedPath = normalizePath(path); + boolean deleted = false; + for (StoreItem item : items) { + if (item.key().equals(normalizedPath) || item.key().startsWith(normalizedPath + "/")) { + store.delete(ns, item.key()); + deleted = true; + } + } + // idempotent — not found is still success + return WriteResult.ok(path); + } + + @Override + public WriteResult move(RuntimeContext runtimeContext, String fromPath, String toPath) { + AbstractFilesystem.validatePath(fromPath); + AbstractFilesystem.validatePath(toPath); + List ns = getNamespace(); + List items = searchAllItems(); + String normFrom = normalizePath(fromPath); + String normTo = normalizePath(toPath); + boolean found = false; + for (StoreItem item : items) { + String key = item.key(); + if (key.equals(normFrom) || key.startsWith(normFrom + "/")) { + String newKey = normTo + key.substring(normFrom.length()); + store.put(ns, newKey, item.value()); + store.delete(ns, key); + found = true; + } + } + if (!found) { + return WriteResult.fail("Source does not exist: " + fromPath); + } + return WriteResult.ok(toPath); + } + + @Override + public boolean exists(RuntimeContext runtimeContext, String path) { + if (path == null || path.isBlank()) { + return false; + } + List ns = getNamespace(); + String normalized = normalizePath(path); + if (store.get(ns, normalized) != null) { + return true; + } + // Also check if any child exists (directory-like) + List items = searchAllItems(); + for (StoreItem item : items) { + if (item.key().startsWith(normalized + "/")) { + return true; + } + } + return false; + } + + // ==================== Internal helpers ==================== + + private List searchAllItems() { + List ns = getNamespace(); + List all = new ArrayList<>(); + int offset = 0; + int pageSize = 100; + while (true) { + List page = store.search(ns, pageSize, offset); + if (page.isEmpty()) { + break; + } + all.addAll(page); + if (page.size() < pageSize) { + break; + } + offset += pageSize; + } + return all; + } + + private static FileData convertItemToFileData(StoreItem item) { + if (item == null || item.value() == null) { + return null; + } + Map value = item.value(); + Object contentObj = value.get("content"); + if (contentObj == null) { + return null; + } + + String content; + if (contentObj instanceof List list) { + StringBuilder sb = new StringBuilder(); + for (int i = 0; i < list.size(); i++) { + if (i > 0) { + sb.append('\n'); + } + sb.append(list.get(i)); + } + content = sb.toString(); + } else if (contentObj instanceof String s) { + content = s; + } else { + return null; + } + + String encoding = + value.containsKey("encoding") ? String.valueOf(value.get("encoding")) : "utf-8"; + String createdAt = + value.containsKey("created_at") ? String.valueOf(value.get("created_at")) : null; + String modifiedAt = + value.containsKey("modified_at") ? String.valueOf(value.get("modified_at")) : null; + + return new FileData(content, encoding, createdAt, modifiedAt); + } + + private static Map fileDataToStoreValue(FileData fd) { + Map result = new HashMap<>(); + result.put("content", fd.content()); + result.put("encoding", fd.encoding()); + if (fd.createdAt() != null) { + result.put("created_at", fd.createdAt()); + } + if (fd.modifiedAt() != null) { + result.put("modified_at", fd.modifiedAt()); + } + return result; + } + + private static String normalizePath(String path) { + if (path == null || path.isBlank()) { + return "/"; + } + String normalized = path.startsWith("/") ? path : "/" + path; + if (!"/".equals(normalized) && normalized.endsWith("/")) { + normalized = normalized.substring(0, normalized.length() - 1); + } + return normalized; + } + + private static boolean matchesPathPrefix(String key, String normalizedPath) { + if ("/".equals(normalizedPath)) { + return key.startsWith("/"); + } + return key.equals(normalizedPath) || key.startsWith(normalizedPath + "/"); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/RemoteFilesystemSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/RemoteFilesystemSpec.java new file mode 100644 index 000000000..3d21b3cc2 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/RemoteFilesystemSpec.java @@ -0,0 +1,190 @@ +/* + * 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.filesystem; + +import io.agentscope.harness.agent.IsolationScope; +import io.agentscope.harness.agent.store.BaseStore; +import io.agentscope.harness.agent.store.NamespaceFactory; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Supplier; + +/** + * Specification for the non-sandbox "composite" filesystem mode. + * + *

    This spec produces a {@link CompositeFilesystem} that blends: + * + *

      + *
    • a plain {@link LocalFilesystem} (no shell) for workspace-local, ephemeral files such as + * skills/, knowledge/, additional context files, etc.; + *
    • a shared {@link RemoteFilesystem} for cross-node paths that must be identical across all + * replicas (long-term memory, offloaded session logs). + *
    + * + *

    Because the default backend is {@link LocalFilesystem} (not {@link LocalFilesystemWithShell}), + * shell execution is intentionally not available in this mode — use a sandbox filesystem spec or + * {@link LocalFilesystemWithShell} if shell is required. + * + *

    Default shared routes: + * + *

      + *
    • {@code MEMORY.md} + *
    • {@code memory/} + *
    • {@code agents//sessions/} + *
    + * + *

    The store namespace for shared files is controlled by {@link #isolationScope(IsolationScope)}, + * which mirrors the sandbox isolation semantics: + * + *

      + *
    • {@link IsolationScope#SESSION} — namespace per session
    • + *
    • {@link IsolationScope#USER} (default) — namespace per user, shared across sessions
    • + *
    • {@link IsolationScope#AGENT} — namespace per agent, shared across all users
    • + *
    • {@link IsolationScope#GLOBAL} — single global namespace
    • + *
    + */ +public class RemoteFilesystemSpec { + + private final BaseStore store; + private final Set extraSharedPrefixes = new LinkedHashSet<>(); + private String anonymousUserId = "_default"; + private IsolationScope isolationScope = IsolationScope.USER; + + public RemoteFilesystemSpec(BaseStore store) { + if (store == null) { + throw new IllegalArgumentException("store must not be null"); + } + this.store = store; + } + + /** + * Adds an extra workspace-relative prefix routed to the shared store. + * + *

    Examples: {@code knowledge/}, {@code prompts/}. + */ + public RemoteFilesystemSpec addSharedPrefix(String prefix) { + if (prefix != null && !prefix.isBlank()) { + extraSharedPrefixes.add(normalizePrefix(prefix)); + } + return this; + } + + /** + * Sets the fallback user identifier when runtime {@code userId} is absent/blank. + */ + public RemoteFilesystemSpec anonymousUserId(String userId) { + if (userId == null || userId.isBlank()) { + throw new IllegalArgumentException("anonymous user id must not be blank"); + } + this.anonymousUserId = userId; + return this; + } + + /** + * Sets the isolation scope that controls the store namespace for shared files. + * + *

    Mirrors the sandbox {@link io.agentscope.harness.agent.sandbox.SandboxContext} isolation + * semantics. Defaults to {@link IsolationScope#USER}. + * + * @param scope isolation scope + * @return this spec + */ + public RemoteFilesystemSpec isolationScope(IsolationScope scope) { + if (scope == null) { + throw new IllegalArgumentException("isolation scope must not be null"); + } + this.isolationScope = scope; + return this; + } + + /** + * Builds the effective filesystem: + * + *

      + *
    • default backend: {@link LocalFilesystem} (no shell) + *
    • shared routes: {@link RemoteFilesystem} with scope-driven namespace + *
    + */ + public AbstractFilesystem toFilesystem( + Path workspace, + String agentId, + NamespaceFactory localNamespaceFactory, + Supplier userIdSupplier, + Supplier sessionIdSupplier) { + String effectiveAgentId = agentId == null || agentId.isBlank() ? "HarnessAgent" : agentId; + AbstractFilesystem local = new LocalFilesystem(workspace, false, 10, localNamespaceFactory); + RemoteFilesystem shared = + new RemoteFilesystem( + store, storeNamespace(effectiveAgentId, userIdSupplier, sessionIdSupplier)); + + Map routes = new LinkedHashMap<>(); + routes.put("MEMORY.md", shared); + routes.put("memory/", shared); + routes.put("agents/" + effectiveAgentId + "/sessions/", shared); + for (String extra : extraSharedPrefixes) { + routes.put(extra, shared); + } + return new CompositeFilesystem(local, routes); + } + + /** + * Builds the effective filesystem without session-level isolation. + * + *

    Convenience overload for callers that don't provide a session-id supplier. + * Equivalent to calling {@link #toFilesystem(Path, String, NamespaceFactory, Supplier, Supplier)} + * with {@code () -> null} as the sessionIdSupplier. + */ + public AbstractFilesystem toFilesystem( + Path workspace, + String agentId, + NamespaceFactory localNamespaceFactory, + Supplier userIdSupplier) { + return toFilesystem(workspace, agentId, localNamespaceFactory, userIdSupplier, () -> null); + } + + private NamespaceFactory storeNamespace( + String agentId, Supplier userIdSupplier, Supplier sessionIdSupplier) { + return () -> { + String uid = userIdSupplier != null ? userIdSupplier.get() : null; + String sid = sessionIdSupplier != null ? sessionIdSupplier.get() : null; + + return switch (isolationScope) { + case SESSION -> { + String effectiveSid = (sid != null && !sid.isBlank()) ? sid : "default"; + yield List.of("agents", agentId, "sessions", effectiveSid); + } + case USER -> { + String effectiveUid = (uid != null && !uid.isBlank()) ? uid : anonymousUserId; + yield List.of("agents", agentId, "users", effectiveUid); + } + case AGENT -> List.of("agents", agentId, "shared"); + case GLOBAL -> List.of("global"); + }; + }; + } + + private static String normalizePrefix(String prefix) { + String normalized = prefix.replace('\\', '/').strip(); + while (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + return normalized; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/EditResult.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/EditResult.java new file mode 100644 index 000000000..29c8d3649 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/EditResult.java @@ -0,0 +1,38 @@ +/* + * 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.filesystem.model; + +/** + * Result of a file edit (string replacement) operation. + * + * @param path absolute path of edited file, {@code null} on failure + * @param error error message on failure, {@code null} on success + * @param occurrences number of replacements made, {@code null} on failure + */ +public record EditResult(String path, String error, Integer occurrences) { + + public static EditResult ok(String path, int occurrences) { + return new EditResult(path, null, occurrences); + } + + public static EditResult fail(String error) { + return new EditResult(null, error, null); + } + + public boolean isSuccess() { + return error == null; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/ExecuteResponse.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/ExecuteResponse.java new file mode 100644 index 000000000..94fc84944 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/ExecuteResponse.java @@ -0,0 +1,30 @@ +/* + * 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.filesystem.model; + +/** + * Result of code/shell execution. + * + * @param output combined stdout and stderr output of the executed command + * @param exitCode the process exit code (0 indicates success, non-zero indicates failure) + * @param truncated whether the output was truncated due to filesystem limitations + */ +public record ExecuteResponse(String output, Integer exitCode, boolean truncated) { + + public boolean isSuccess() { + return exitCode != null && exitCode == 0; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/FileData.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/FileData.java new file mode 100644 index 000000000..de120d434 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/FileData.java @@ -0,0 +1,49 @@ +/* + * 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.filesystem.model; + +import java.time.Instant; + +/** + * Data structure for storing file contents with metadata. + * + * @param content file content as a plain string (utf-8 text or base64-encoded binary) + * @param encoding content encoding: {@code "utf-8"} for text, {@code "base64"} for binary + * @param createdAt ISO 8601 timestamp of file creation (nullable) + * @param modifiedAt ISO 8601 timestamp of last modification (nullable) + */ +public record FileData(String content, String encoding, String createdAt, String modifiedAt) { + + public FileData(String content, String encoding) { + this(content, encoding, null, null); + } + + /** Creates a new UTF-8 text FileData with timestamps set to now. */ + public static FileData create(String content) { + return create(content, "utf-8"); + } + + /** Creates a new FileData with specified encoding and timestamps set to now. */ + public static FileData create(String content, String encoding) { + String now = Instant.now().toString(); + return new FileData(content, encoding, now, now); + } + + /** Returns a copy with updated content and modified timestamp. */ + public FileData withContent(String newContent) { + return new FileData(newContent, encoding, createdAt, Instant.now().toString()); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/FileDownloadResponse.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/FileDownloadResponse.java new file mode 100644 index 000000000..16caab0e3 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/FileDownloadResponse.java @@ -0,0 +1,38 @@ +/* + * 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.filesystem.model; + +/** + * Result of a single file download operation. + * + * @param path the file path that was requested + * @param content file contents as bytes on success, {@code null} on failure + * @param error error description on failure, {@code null} on success + */ +public record FileDownloadResponse(String path, byte[] content, String error) { + + public static FileDownloadResponse success(String path, byte[] content) { + return new FileDownloadResponse(path, content, null); + } + + public static FileDownloadResponse fail(String path, String error) { + return new FileDownloadResponse(path, null, error); + } + + public boolean isSuccess() { + return error == null; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/FileInfo.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/FileInfo.java new file mode 100644 index 000000000..e73cb7a34 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/FileInfo.java @@ -0,0 +1,46 @@ +/* + * 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.filesystem.model; + +/** + * Structured file listing info. Only {@code path} is required; + * other fields are best-effort and may be absent depending on filesystem implementation. + * + * @param path absolute or relative file path + * @param isDirectory whether the entry is a directory + * @param size file size in bytes (approximate) + * @param modifiedAt ISO 8601 timestamp of last modification, or empty string if unknown + */ +public record FileInfo(String path, boolean isDirectory, long size, String modifiedAt) { + + public static FileInfo ofFile(String path, long size, String modifiedAt) { + return new FileInfo(path, false, size, modifiedAt != null ? modifiedAt : ""); + } + + public static FileInfo ofDir(String path, String modifiedAt) { + return new FileInfo(path, true, 0, modifiedAt != null ? modifiedAt : ""); + } + + public static FileInfo ofFile(String path, long size, long lastModifiedMs) { + return new FileInfo( + path, false, size, java.time.Instant.ofEpochMilli(lastModifiedMs).toString()); + } + + public static FileInfo ofDir(String path, long lastModifiedMs) { + return new FileInfo( + path, true, 0, java.time.Instant.ofEpochMilli(lastModifiedMs).toString()); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/FileUploadResponse.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/FileUploadResponse.java new file mode 100644 index 000000000..b920068e3 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/FileUploadResponse.java @@ -0,0 +1,37 @@ +/* + * 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.filesystem.model; + +/** + * Result of a single file upload operation. + * + * @param path the file path that was requested + * @param error error description on failure, {@code null} on success + */ +public record FileUploadResponse(String path, String error) { + + public static FileUploadResponse success(String path) { + return new FileUploadResponse(path, null); + } + + public static FileUploadResponse fail(String path, String error) { + return new FileUploadResponse(path, error); + } + + public boolean isSuccess() { + return error == null; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/GlobResult.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/GlobResult.java new file mode 100644 index 000000000..b1b59b0ab --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/GlobResult.java @@ -0,0 +1,39 @@ +/* + * 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.filesystem.model; + +import java.util.List; + +/** + * Result from abstract filesystem glob operations. + * + * @param error error message on failure, {@code null} on success + * @param matches list of matching file info entries on success, {@code null} on failure + */ +public record GlobResult(String error, List matches) { + + public static GlobResult success(List matches) { + return new GlobResult(null, matches); + } + + public static GlobResult fail(String error) { + return new GlobResult(error, null); + } + + public boolean isSuccess() { + return error == null; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/GrepMatch.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/GrepMatch.java new file mode 100644 index 000000000..9f1a9ebd2 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/GrepMatch.java @@ -0,0 +1,25 @@ +/* + * 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.filesystem.model; + +/** + * A single match from a grep search. + * + * @param path path to the file containing the match + * @param line 1-indexed line number of the match + * @param text content of the matching line + */ +public record GrepMatch(String path, int line, String text) {} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/GrepResult.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/GrepResult.java new file mode 100644 index 000000000..14de81a4e --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/GrepResult.java @@ -0,0 +1,39 @@ +/* + * 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.filesystem.model; + +import java.util.List; + +/** + * Result from abstract filesystem grep operations. + * + * @param error error message on failure, {@code null} on success + * @param matches list of grep matches on success, {@code null} on failure + */ +public record GrepResult(String error, List matches) { + + public static GrepResult success(List matches) { + return new GrepResult(null, matches); + } + + public static GrepResult fail(String error) { + return new GrepResult(error, null); + } + + public boolean isSuccess() { + return error == null; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/LsResult.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/LsResult.java new file mode 100644 index 000000000..39f25c68d --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/LsResult.java @@ -0,0 +1,39 @@ +/* + * 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.filesystem.model; + +import java.util.List; + +/** + * Result from abstract filesystem ls (directory listing) operations. + * + * @param error error message on failure, {@code null} on success + * @param entries list of file info entries on success, {@code null} on failure + */ +public record LsResult(String error, List entries) { + + public static LsResult success(List entries) { + return new LsResult(null, entries); + } + + public static LsResult fail(String error) { + return new LsResult(error, null); + } + + public boolean isSuccess() { + return error == null; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/ReadResult.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/ReadResult.java new file mode 100644 index 000000000..e0844fb20 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/ReadResult.java @@ -0,0 +1,37 @@ +/* + * 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.filesystem.model; + +/** + * Result from abstract filesystem read operations. + * + * @param error error message on failure, {@code null} on success + * @param fileData file data on success, {@code null} on failure + */ +public record ReadResult(String error, FileData fileData) { + + public static ReadResult success(FileData fileData) { + return new ReadResult(null, fileData); + } + + public static ReadResult fail(String error) { + return new ReadResult(error, null); + } + + public boolean isSuccess() { + return error == null; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/WriteResult.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/WriteResult.java new file mode 100644 index 000000000..b3122e4de --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/model/WriteResult.java @@ -0,0 +1,37 @@ +/* + * 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.filesystem.model; + +/** + * Result of a file write operation. + * + * @param path absolute path of written file, {@code null} on failure + * @param error error message on failure, {@code null} on success + */ +public record WriteResult(String path, String error) { + + public static WriteResult ok(String path) { + return new WriteResult(path, null); + } + + public static WriteResult fail(String error) { + return new WriteResult(null, error); + } + + public boolean isSuccess() { + return error == null; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/util/FilesystemUtils.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/util/FilesystemUtils.java new file mode 100644 index 000000000..851deb21d --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/util/FilesystemUtils.java @@ -0,0 +1,107 @@ +/* + * 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.filesystem.util; + +import java.util.Set; + +/** + * Shared utility functions for filesystem implementations. + */ +public final class FilesystemUtils { + + private FilesystemUtils() {} + + private static final Set BINARY_EXTENSIONS = + Set.of( + ".png", ".jpg", ".jpeg", ".gif", ".webp", ".heic", ".heif", ".bmp", ".ico", + ".svg", ".mp4", ".mpeg", ".mov", ".avi", ".flv", ".mpg", ".webm", ".wmv", + ".3gpp", ".wav", ".mp3", ".aiff", ".aac", ".ogg", ".flac", ".pdf", ".ppt", + ".pptx", ".doc", ".docx", ".xls", ".xlsx", ".zip", ".tar", ".gz", ".bz2", ".7z", + ".rar", ".class", ".jar", ".war", ".ear", ".so", ".dll", ".dylib", ".exe"); + + /** + * Classify a file as "text" or "binary" based on extension. + */ + public static String getFileType(String path) { + if (path == null) { + return "text"; + } + int dot = path.lastIndexOf('.'); + if (dot < 0) { + return "text"; + } + String ext = path.substring(dot).toLowerCase(); + return BINARY_EXTENSIONS.contains(ext) ? "binary" : "text"; + } + + /** + * Perform string replacement with occurrence validation. + * + * @return a two-element array {@code [newContent, occurrenceCount]} on success, + * or a single-element array {@code [errorMessage]} on failure + */ + public static Object[] performStringReplacement( + String content, String oldString, String newString, boolean replaceAll) { + int occurrences = countOccurrences(content, oldString); + + if (occurrences == 0) { + return new Object[] {"Error: String not found in file: '" + oldString + "'"}; + } + + if (occurrences > 1 && !replaceAll) { + return new Object[] { + "Error: String '" + + oldString + + "' appears " + + occurrences + + " times in file. " + + "Use replaceAll=true to replace all instances, or provide a more specific" + + " string with surrounding context." + }; + } + + String newContent; + if (replaceAll) { + newContent = content.replace(oldString, newString); + } else { + int idx = content.indexOf(oldString); + newContent = + content.substring(0, idx) + + newString + + content.substring(idx + oldString.length()); + } + return new Object[] {newContent, occurrences}; + } + + /** Count non-overlapping occurrences of a substring. */ + public static int countOccurrences(String text, String sub) { + int count = 0; + int idx = 0; + while ((idx = text.indexOf(sub, idx)) >= 0) { + count++; + idx += sub.length(); + } + return count; + } + + /** Shell-escape a string for safe use in shell commands. */ + public static String shellQuote(String s) { + if (s == null || s.isEmpty()) { + return "''"; + } + return "'" + s.replace("'", "'\\''") + "'"; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/AgentTraceHook.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/AgentTraceHook.java new file mode 100644 index 000000000..5f3c05567 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/AgentTraceHook.java @@ -0,0 +1,241 @@ +/* + * 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.hook; + +import io.agentscope.core.hook.ErrorEvent; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PostActingEvent; +import io.agentscope.core.hook.PostCallEvent; +import io.agentscope.core.hook.PostReasoningEvent; +import io.agentscope.core.hook.PreActingEvent; +import io.agentscope.core.hook.PreCallEvent; +import io.agentscope.core.hook.PreReasoningEvent; +import io.agentscope.core.hook.PreSummaryEvent; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.util.JsonUtils; +import java.util.List; +import java.util.Map; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Observability hook that logs the complete reasoning and execution trace of an agent. + * + *

    At INFO level, logs concise summaries: event type, agent name, tool names/IDs, and + * message lengths. At DEBUG level, additionally logs tool call arguments, tool result content, + * reasoning text, and input message details. + * + *

    This hook runs at the lowest priority (first in, last out) so it captures all events + * without interfering with other hooks. + */ +public class AgentTraceHook implements Hook { + + private static final Logger log = LoggerFactory.getLogger(AgentTraceHook.class); + + @Override + public int priority() { + return 0; + } + + @Override + public Mono onEvent(T event) { + if (!log.isInfoEnabled()) { + return Mono.just(event); + } + + String agent = event.getAgent().getName(); + + if (event instanceof PreCallEvent e) { + logPreCall(agent, e); + } else if (event instanceof PostCallEvent e) { + logPostCall(agent, e); + } else if (event instanceof PreReasoningEvent e) { + logPreReasoning(agent, e); + } else if (event instanceof PostReasoningEvent e) { + logPostReasoning(agent, e); + } else if (event instanceof PreActingEvent e) { + logPreActing(agent, e); + } else if (event instanceof PostActingEvent e) { + logPostActing(agent, e); + } else if (event instanceof PreSummaryEvent e) { + logPreSummary(agent, e); + } else if (event instanceof ErrorEvent e) { + logError(agent, e); + } + + return Mono.just(event); + } + + private void logPreCall(String agent, PreCallEvent e) { + List msgs = e.getInputMessages(); + log.info("[{}] PRE_CALL | {} input message(s)", agent, msgs != null ? msgs.size() : 0); + if (log.isDebugEnabled() && msgs != null) { + for (Msg msg : msgs) { + log.debug( + "[{}] PRE_CALL | [{}] {}", + agent, + msg.getRole(), + truncate(msg.getTextContent(), 200)); + } + } + } + + private void logPostCall(String agent, PostCallEvent e) { + Msg msg = e.getFinalMessage(); + String preview = msg != null ? truncate(msg.getTextContent(), 120) : ""; + log.info("[{}] POST_CALL | response: {}", agent, preview); + } + + private void logPreReasoning(String agent, PreReasoningEvent e) { + int msgCount = e.getInputMessages() != null ? e.getInputMessages().size() : 0; + log.info("[{}] PRE_REASONING | model={}, messages={}", agent, e.getModelName(), msgCount); + if (log.isDebugEnabled() && e.getInputMessages() != null) { + for (Msg msg : e.getInputMessages()) { + log.debug( + "[{}] PRE_REASONING | [{}] len={}", + agent, + msg.getRole(), + msg.getTextContent() != null ? msg.getTextContent().length() : 0); + } + } + } + + private void logPostReasoning(String agent, PostReasoningEvent e) { + Msg msg = e.getReasoningMessage(); + if (msg == null) { + log.info("[{}] POST_REASONING | ", agent); + return; + } + List toolCalls = msg.getContentBlocks(ToolUseBlock.class); + if (toolCalls.isEmpty()) { + String text = truncate(msg.getTextContent(), 120); + log.info("[{}] POST_REASONING | text response: {}", agent, text); + } else { + for (ToolUseBlock tu : toolCalls) { + log.info( + "[{}] POST_REASONING | tool_call: id={}, name={}", + agent, + tu.getId(), + tu.getName()); + if (log.isDebugEnabled()) { + log.debug( + "[{}] POST_REASONING | args={}", + agent, + truncate(mapToJson(tu.getInput()), 500)); + } + } + } + } + + private void logPreActing(String agent, PreActingEvent e) { + ToolUseBlock tu = e.getToolUse(); + if (tu == null) { + return; + } + log.info("[{}] PRE_ACTING | id={}, name={}", agent, tu.getId(), tu.getName()); + if (log.isDebugEnabled()) { + log.debug( + "[{}] PRE_ACTING | args={}", agent, truncate(mapToJson(tu.getInput()), 500)); + } + } + + private void logPostActing(String agent, PostActingEvent e) { + ToolUseBlock tu = e.getToolUse(); + ToolResultBlock tr = e.getToolResult(); + String toolName = tu != null ? tu.getName() : "?"; + String toolId = tu != null ? tu.getId() : "?"; + int resultLen = toolResultLength(tr); + log.info( + "[{}] POST_ACTING | id={}, name={}, result_len={}", + agent, + toolId, + toolName, + resultLen); + if (log.isDebugEnabled() && tr != null) { + log.debug("[{}] POST_ACTING | result={}", agent, truncate(toolResultText(tr), 500)); + } + } + + private void logPreSummary(String agent, PreSummaryEvent e) { + log.info( + "[{}] PRE_SUMMARY | model={}, iter={}/{}", + agent, + e.getModelName(), + e.getCurrentIteration(), + e.getMaxIterations()); + } + + private void logError(String agent, ErrorEvent e) { + Throwable err = e.getError(); + log.info("[{}] ERROR | {}: {}", agent, err.getClass().getSimpleName(), err.getMessage()); + if (log.isDebugEnabled()) { + log.debug("[{}] ERROR | stacktrace:", agent, err); + } + } + + private static String truncate(String s, int max) { + if (s == null || s.isEmpty()) { + return ""; + } + if (s.length() <= max) { + return s; + } + return s.substring(0, max) + "...[truncated, limit=" + max + " chars]"; + } + + private static String mapToJson(Map map) { + if (map == null || map.isEmpty()) { + return "{}"; + } + try { + return JsonUtils.getJsonCodec().toJson(map); + } catch (Exception e) { + return map.toString(); + } + } + + private static int toolResultLength(ToolResultBlock tr) { + if (tr == null || tr.getOutput() == null) { + return 0; + } + int len = 0; + for (ContentBlock block : tr.getOutput()) { + if (block instanceof TextBlock tb && tb.getText() != null) { + len += tb.getText().length(); + } + } + return len; + } + + private static String toolResultText(ToolResultBlock tr) { + if (tr == null || tr.getOutput() == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (ContentBlock block : tr.getOutput()) { + if (block instanceof TextBlock tb && tb.getText() != null) { + sb.append(tb.getText()); + } + } + return sb.toString(); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/CompactionHook.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/CompactionHook.java new file mode 100644 index 000000000..3a13f1fe2 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/CompactionHook.java @@ -0,0 +1,173 @@ +/* + * 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.hook; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PreReasoningEvent; +import io.agentscope.core.hook.RuntimeContextAware; +import io.agentscope.core.memory.Memory; +import io.agentscope.core.message.Msg; +import io.agentscope.core.model.Model; +import io.agentscope.harness.agent.memory.MemoryFlushManager; +import io.agentscope.harness.agent.memory.compaction.CompactionConfig; +import io.agentscope.harness.agent.memory.compaction.ConversationCompactor; +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Hook that performs conversation compaction before each LLM reasoning call. + * + *

    Fires on {@link PreReasoningEvent}. When the compaction threshold is exceeded: + *

      + *
    1. Long-term memories are flushed from the prefix via {@link MemoryFlushManager}.
    2. + *
    3. The full conversation is offloaded to the session JSONL.
    4. + *
    5. The prefix is distilled into a structured summary via one LLM call.
    6. + *
    7. The agent's working {@link Memory} is replaced with + * {@code [summaryMsg] + preservedTail}.
    8. + *
    9. {@link PreReasoningEvent#setInputMessages} is updated to the compacted conversation + * ({@code [summaryMsg] + preservedTail}). The system message is managed separately in + * {@link PreReasoningEvent#getSystemMessage()} and prepended by {@code ReActAgent} + * just before {@code model.stream()}, so this hook no longer splits or re-merges + * SYSTEM messages.
    10. + *
    + * + *

    This hook runs at priority 10 — before + * {@link io.agentscope.harness.agent.hook.WorkspaceContextHook} (priority 900): compaction + * runs on the conversation first; the workspace context is then appended to the system message + * by {@code WorkspaceContextHook} on the same {@link PreReasoningEvent} chain. + * + *

    {@link RuntimeContext} is bound on each call by {@link io.agentscope.core.ReActAgent}. + */ +public class CompactionHook implements Hook, RuntimeContextAware { + + private static final Logger log = LoggerFactory.getLogger(CompactionHook.class); + + private final WorkspaceManager workspaceManager; + private final Model model; + private final CompactionConfig config; + + private RuntimeContext runtimeContext; + + public CompactionHook(WorkspaceManager workspaceManager, Model model, CompactionConfig config) { + this.workspaceManager = workspaceManager; + this.model = model; + this.config = config; + } + + @Override + public void setRuntimeContext(RuntimeContext runtimeContext) { + this.runtimeContext = runtimeContext; + } + + @Override + public int priority() { + return 10; + } + + @Override + @SuppressWarnings("unchecked") + public Mono onEvent(T event) { + if (event instanceof PreReasoningEvent pre) { + // Must emit a value: Reactor's thenReturn() would not run if the source completed + // "empty" (e.g. Mono.empty() from flatMap), which would drop all later hooks. + return (Mono) (Mono) handlePreReasoning(pre); + } + return Mono.just(event); + } + + // ------------------------------------------------------------------------- + // Core compaction flow + // ------------------------------------------------------------------------- + + private Mono handlePreReasoning(PreReasoningEvent event) { + if (!(event.getAgent() instanceof ReActAgent reActAgent)) { + return Mono.just(event); + } + + // inputMessages contains only conversation messages — SYSTEM is managed separately + // via event.getSystemMessage() / event.setSystemMessage() + List conversationMsgs = event.getInputMessages(); + + String agentId = event.getAgent().getName(); + String sessionId = sessionId(); + + MemoryFlushManager flushManager = buildFlushManager(); + ConversationCompactor compactor = new ConversationCompactor(model, flushManager); + + return compactor + .compactIfNeeded(conversationMsgs, config, agentId, sessionId) + .flatMap( + optResult -> { + if (optResult.isEmpty()) { + return Mono.just(event); + } + List compacted = optResult.get(); + applyToMemory(reActAgent.getMemory(), compacted); + event.setInputMessages(compacted); + log.debug( + "Updated PreReasoningEvent to {} compacted messages", + compacted.size()); + return Mono.just(event); + }) + .onErrorResume( + e -> { + log.warn( + "Compaction failed, continuing without compaction: {}", + e.getMessage()); + return Mono.just(event); + }); + } + + /** + * Replaces the agent's working memory with the compacted message list. + * + *

    Uses {@link Memory#clear()} + {@link Memory#addMessage(Msg)} to synchronise the + * in-memory state so subsequent reasoning rounds start from the compacted baseline. + */ + private static void applyToMemory(Memory memory, List compacted) { + try { + memory.clear(); + for (Msg msg : compacted) { + memory.addMessage(msg); + } + log.debug("Applied compacted messages to memory ({} messages)", compacted.size()); + } catch (Exception e) { + log.warn("Failed to apply compacted messages to memory: {}", e.getMessage()); + } + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private MemoryFlushManager buildFlushManager() { + return new MemoryFlushManager(workspaceManager, model); + } + + private String sessionId() { + RuntimeContext ctx = this.runtimeContext; + if (ctx != null && ctx.getSessionId() != null) { + return ctx.getSessionId(); + } + return "default"; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/MemoryFlushHook.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/MemoryFlushHook.java new file mode 100644 index 000000000..74b02fae6 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/MemoryFlushHook.java @@ -0,0 +1,118 @@ +/* + * 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.hook; + +import io.agentscope.core.ReActAgent; +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PostCallEvent; +import io.agentscope.core.hook.RuntimeContextAware; +import io.agentscope.core.memory.Memory; +import io.agentscope.core.message.Msg; +import io.agentscope.core.model.Model; +import io.agentscope.harness.agent.memory.MemoryFlushManager; +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Hook that triggers memory flush and message offload at the end of each agent call. + * + *

    Fires on {@link PostCallEvent} to ensure long-term memories are extracted and + * persisted after every call, even when conversation compaction was not triggered during + * that call. When CompactionHook is active, it handles flush/offload for the messages it summarizes; + * this hook covers the remaining tail of messages that were kept verbatim. + * + *

    Priority is 5 — runs early so state is persisted before the session-persistence hook + * (priority 900) saves the overall agent state. + */ +public class MemoryFlushHook implements Hook, RuntimeContextAware { + + private static final Logger log = LoggerFactory.getLogger(MemoryFlushHook.class); + + private final WorkspaceManager workspaceManager; + private final Model model; + private RuntimeContext runtimeContext; + + public MemoryFlushHook(WorkspaceManager workspaceManager, Model model) { + this.workspaceManager = workspaceManager; + this.model = model; + } + + @Override + public void setRuntimeContext(RuntimeContext runtimeContext) { + this.runtimeContext = runtimeContext; + } + + @Override + public Mono onEvent(T event) { + if (event instanceof PostCallEvent) { + return doFlush(event.getAgent()).thenReturn(event); + } + return Mono.just(event); + } + + @Override + public int priority() { + return 5; + } + + private Mono doFlush(Agent agent) { + if (!(agent instanceof ReActAgent reActAgent)) { + return Mono.empty(); + } + + Memory memory = reActAgent.getMemory(); + List messages = memory.getMessages(); + if (messages.isEmpty()) { + return Mono.empty(); + } + + MemoryFlushManager flushManager = new MemoryFlushManager(workspaceManager, model); + + Mono flushMono = + flushManager + .flushMemories(messages) + .doOnSuccess(v -> log.debug("Memory flush completed")) + .onErrorResume( + e -> { + log.warn("Memory flush failed: {}", e.getMessage()); + return Mono.empty(); + }); + + String agentId = agent.getName(); + String sessionId = + runtimeContext != null && runtimeContext.getSessionId() != null + ? runtimeContext.getSessionId() + : "default"; + + Mono offloadMono = + Mono.fromRunnable(() -> flushManager.offloadMessages(messages, agentId, sessionId)) + .then() + .doOnSuccess(v -> log.debug("Message offload completed")) + .onErrorResume( + e -> { + log.warn("Message offload failed: {}", e.getMessage()); + return Mono.empty(); + }); + + return flushMono.then(offloadMono); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/MemoryMaintenanceHook.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/MemoryMaintenanceHook.java new file mode 100644 index 000000000..9c13c11ce --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/MemoryMaintenanceHook.java @@ -0,0 +1,213 @@ +/* + * 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.hook; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PostCallEvent; +import io.agentscope.harness.agent.filesystem.AbstractFilesystem; +import io.agentscope.harness.agent.filesystem.model.FileInfo; +import io.agentscope.harness.agent.filesystem.model.GlobResult; +import io.agentscope.harness.agent.memory.MemoryConsolidator; +import io.agentscope.harness.agent.workspace.WorkspaceConstants; +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.time.Duration; +import java.time.Instant; +import java.time.LocalDate; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Hook that performs periodic memory maintenance after each agent call. + * + *

    Replaces the background {@code MemoryMaintenanceScheduler} with a hook-driven, + * event-loop-friendly approach. Fires on {@link PostCallEvent} (priority 6, after + * {@link MemoryFlushHook} at priority 5) and is throttled by a configurable minimum gap + * so it does not run on every single call. + * + *

    Maintenance steps executed in order: + *

      + *
    1. Expire daily memory files older than {@code dailyFileRetentionDays} by moving + * them to {@code memory/archive/}.
    2. + *
    3. Run LLM-based consolidation ({@link MemoryConsolidator#consolidate()}) if a + * consolidator is configured.
    4. + *
    5. Prune session log files older than {@code sessionRetentionDays}.
    6. + *
    + * + *

    All file I/O goes through {@link AbstractFilesystem} (obtained from + * {@link WorkspaceManager}), making this backend-agnostic across Local, Sandbox, and + * Remote filesystems. + */ +public class MemoryMaintenanceHook implements Hook { + + private static final RuntimeContext DEFAULT_FS_RUNTIME = RuntimeContext.empty(); + + private static final Logger log = LoggerFactory.getLogger(MemoryMaintenanceHook.class); + + /** Default minimum gap between two maintenance runs. */ + public static final Duration DEFAULT_MIN_GAP = Duration.ofMinutes(30); + + private final WorkspaceManager workspaceManager; + private final MemoryConsolidator consolidator; + private final int dailyFileRetentionDays; + private final int sessionRetentionDays; + private final Duration minGap; + + private final AtomicReference lastRunAt = new AtomicReference<>(Instant.EPOCH); + + public MemoryMaintenanceHook( + WorkspaceManager workspaceManager, + MemoryConsolidator consolidator, + int dailyFileRetentionDays, + int sessionRetentionDays, + Duration minGap) { + this.workspaceManager = workspaceManager; + this.consolidator = consolidator; + this.dailyFileRetentionDays = dailyFileRetentionDays; + this.sessionRetentionDays = sessionRetentionDays; + this.minGap = minGap != null ? minGap : DEFAULT_MIN_GAP; + } + + public MemoryMaintenanceHook( + WorkspaceManager workspaceManager, MemoryConsolidator consolidator) { + this(workspaceManager, consolidator, 90, 180, DEFAULT_MIN_GAP); + } + + @Override + public int priority() { + return 6; + } + + @Override + public Mono onEvent(T event) { + if (!(event instanceof PostCallEvent)) { + return Mono.just(event); + } + Instant now = Instant.now(); + Instant last = lastRunAt.get(); + if (Duration.between(last, now).compareTo(minGap) < 0) { + return Mono.just(event); + } + if (!lastRunAt.compareAndSet(last, now)) { + return Mono.just(event); + } + return Mono.fromRunnable(this::runMaintenance) + .onErrorResume( + e -> { + log.warn("Memory maintenance failed: {}", e.getMessage()); + return Mono.empty(); + }) + .thenReturn(event); + } + + private void runMaintenance() { + log.debug("Running memory maintenance..."); + expireDailyFiles(); + consolidateMemory(); + pruneOldSessions(); + log.debug("Memory maintenance completed"); + } + + private void expireDailyFiles() { + AbstractFilesystem fs = workspaceManager.getFilesystem(); + if (fs == null) { + return; + } + GlobResult glob = fs.glob(DEFAULT_FS_RUNTIME, "*.md", WorkspaceConstants.MEMORY_DIR); + if (glob == null || glob.matches() == null) { + return; + } + + LocalDate cutoff = LocalDate.now().minusDays(dailyFileRetentionDays); + for (FileInfo fi : glob.matches()) { + if (fi.isDirectory()) { + continue; + } + String fileName = fileName(fi.path()); + if (fileName.startsWith(".")) { + continue; + } + String baseName = + fileName.endsWith(".md") + ? fileName.substring(0, fileName.length() - 3) + : fileName; + try { + LocalDate fileDate = LocalDate.parse(baseName); + if (fileDate.isBefore(cutoff)) { + String fromPath = WorkspaceConstants.MEMORY_DIR + "/" + fileName; + String toPath = WorkspaceConstants.MEMORY_DIR + "/archive/" + fileName; + fs.move(DEFAULT_FS_RUNTIME, fromPath, toPath); + log.debug("Archived expired daily file: {}", fileName); + } + } catch (Exception e) { + // not a date-named file, skip + } + } + } + + private void consolidateMemory() { + if (consolidator == null) { + return; + } + try { + consolidator.consolidate().block(); + } catch (Exception e) { + log.warn("Memory consolidation failed: {}", e.getMessage()); + } + } + + private void pruneOldSessions() { + AbstractFilesystem fs = workspaceManager.getFilesystem(); + if (fs == null) { + return; + } + GlobResult glob = fs.glob(DEFAULT_FS_RUNTIME, "*.log.jsonl", WorkspaceConstants.AGENTS_DIR); + if (glob == null || glob.matches() == null) { + return; + } + + Instant cutoff = Instant.now().minus(Duration.ofDays(sessionRetentionDays)); + for (FileInfo fi : glob.matches()) { + if (fi.isDirectory()) { + continue; + } + String modifiedAt = fi.modifiedAt(); + if (modifiedAt == null || modifiedAt.isEmpty()) { + continue; + } + try { + Instant modified = Instant.parse(modifiedAt); + if (modified.isBefore(cutoff)) { + fs.delete(DEFAULT_FS_RUNTIME, fi.path()); + log.debug("Pruned old session file: {}", fi.path()); + } + } catch (Exception e) { + log.warn("Failed to check/prune {}: {}", fi.path(), e.getMessage()); + } + } + } + + private static String fileName(String path) { + if (path == null) { + return ""; + } + int slash = path.lastIndexOf('/'); + return slash >= 0 ? path.substring(slash + 1) : path; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/SandboxLifecycleHook.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/SandboxLifecycleHook.java new file mode 100644 index 000000000..6d746da6e --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/SandboxLifecycleHook.java @@ -0,0 +1,205 @@ +/* + * 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.hook; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.hook.ErrorEvent; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PostCallEvent; +import io.agentscope.core.hook.PreCallEvent; +import io.agentscope.core.hook.RuntimeContextAware; +import io.agentscope.harness.agent.sandbox.Sandbox; +import io.agentscope.harness.agent.sandbox.SandboxAcquireResult; +import io.agentscope.harness.agent.sandbox.SandboxBackedFilesystem; +import io.agentscope.harness.agent.sandbox.SandboxContext; +import io.agentscope.harness.agent.sandbox.SandboxManager; +import java.util.concurrent.atomic.AtomicReference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Hook that manages the sandbox session lifecycle around each agent call. + * + *

    PreCallEvent

    + *
      + *
    1. Read {@link SandboxContext} from the current {@link RuntimeContext}
    2. + *
    3. Acquire a session via {@link SandboxManager}
    4. + *
    5. Start the session (4-branch workspace init)
    6. + *
    7. Inject the live session into the {@link SandboxBackedFilesystem} proxy
    8. + *
    + * + *

    PostCallEvent / ErrorEvent

    + *
      + *
    1. Persist sandbox session state via {@link SandboxManager} and {@link io.agentscope.harness.agent.sandbox.SandboxStateStore}
    2. + *
    3. Release the session via {@link SandboxManager} (stop + optional shutdown)
    4. + *
    5. Clear the session reference from the filesystem proxy
    6. + *
    + * + *

    Priority is set to 50 — runs before all other harness hooks (SessionPersistence at 900). + * + *

    Post-call failures (persist, release) are logged but do not propagate — this ensures + * the agent call result is always returned to the caller even if sandbox cleanup fails. + */ +public class SandboxLifecycleHook implements Hook, RuntimeContextAware { + + private static final Logger log = LoggerFactory.getLogger(SandboxLifecycleHook.class); + + private final SandboxManager sandboxManager; + private final SandboxBackedFilesystem filesystemProxy; + + private RuntimeContext runtimeContext; + + /** + * Holds the acquire result between {@link PreCallEvent} and {@link PostCallEvent}. + * + *

    Not {@link ThreadLocal}: Reactor may resume hook stages on different threads, so a + * thread-local would drop the handle and skip persist/release. A typical {@link + * io.agentscope.harness.agent.HarnessAgent} rejects concurrent calls per agent, so a single + * {@link AtomicReference} is sufficient. + */ + private final AtomicReference currentAcquireResult = + new AtomicReference<>(); + + /** + * Creates the hook. + * + * @param sandboxManager the manager responsible for session acquire/release + * @param filesystemProxy the filesystem proxy that receives injected sessions + */ + public SandboxLifecycleHook( + SandboxManager sandboxManager, SandboxBackedFilesystem filesystemProxy) { + this.sandboxManager = sandboxManager; + this.filesystemProxy = filesystemProxy; + } + + @Override + public int priority() { + return 50; + } + + @Override + public void setRuntimeContext(RuntimeContext ctx) { + this.runtimeContext = ctx; + } + + @SuppressWarnings("unchecked") + @Override + public Mono onEvent(T event) { + if (event instanceof PreCallEvent e) { + return (Mono) handlePreCall(e); + } else if (event instanceof PostCallEvent e) { + return (Mono) handlePost(e); + } else if (event instanceof ErrorEvent e) { + return (Mono) handlePost(e); + } + return Mono.just(event); + } + + @SuppressWarnings("unchecked") + private Mono handlePreCall(T event) { + RuntimeContext ctx = runtimeContext; + if (ctx == null) { + return Mono.just(event); + } + + SandboxContext sandboxContext = ctx.get(SandboxContext.class); + if (sandboxContext == null) { + return Mono.just(event); + } + + return Mono.fromCallable( + () -> { + SandboxAcquireResult result = + sandboxManager.acquire(sandboxContext, ctx); + Sandbox sandbox = result.getSandbox(); + try { + sandbox.start(); + filesystemProxy.setSandbox(sandbox); + currentAcquireResult.set(result); + log.debug( + "[sandbox-hook] Acquired sandbox {}", + sandbox.getState() != null + ? sandbox.getState().getSessionId() + : "?"); + } catch (Exception e) { + filesystemProxy.setSandbox(null); + try { + sandboxManager.release(result); + } catch (Exception releaseErr) { + log.warn( + "[sandbox-hook] Failed to release session after" + + " pre-call failure: {}", + releaseErr.getMessage(), + releaseErr); + } + throw e; + } + return event; + }) + .onErrorMap( + e -> { + log.error("[sandbox-hook] Failed to acquire/start sandbox", e); + return e; + }); + } + + @SuppressWarnings("unchecked") + private Mono handlePost(T event) { + return Mono.fromCallable( + () -> { + SandboxAcquireResult result = currentAcquireResult.getAndSet(null); + + if (result == null) { + log.warn( + "[sandbox-hook] PostCall/ErrorEvent: no in-flight acquire result" + + " (persist/release skipped — was PreCall skipped or ref" + + " already consumed?)"); + return event; + } + + RuntimeContext ctx = runtimeContext; + SandboxContext sandboxContext = + ctx != null ? ctx.get(SandboxContext.class) : null; + + // Persist state first (before release destroys workspace) + try { + sandboxManager.persistState(result, sandboxContext, ctx); + } catch (Exception e) { + log.warn( + "[sandbox-hook] Failed to persist sandbox state: {}", + e.getMessage(), + e); + } + + // Release the session (stop + optional shutdown) + try { + sandboxManager.release(result); + } catch (Exception e) { + log.warn( + "[sandbox-hook] Failed to release sandbox session: {}", + e.getMessage(), + e); + } + + // Clear the session reference from the filesystem proxy + filesystemProxy.setSandbox(null); + + return event; + }); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/SessionPersistenceHook.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/SessionPersistenceHook.java new file mode 100644 index 000000000..700d3b87f --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/SessionPersistenceHook.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.harness.agent.hook; + +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.hook.ErrorEvent; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PostCallEvent; +import io.agentscope.core.hook.RuntimeContextAware; +import io.agentscope.core.state.StateModule; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Hook that automatically persists agent state to the session after each call. + * + *

    Handles both {@link PostCallEvent} (success path) and {@link ErrorEvent} (error path) to + * ensure state is saved regardless of outcome. The save is best-effort: failures are logged but + * do not propagate exceptions. + * + *

    Session and SessionKey are obtained from the {@link RuntimeContext} set per call. When no + * session is configured in the context, + * the hook is a no-op. + * + *

    Priority is set to 900 (low) so this hook runs after other hooks like + * {@link MemoryFlushHook} have completed their work. + */ +public class SessionPersistenceHook implements Hook, RuntimeContextAware { + + private static final Logger log = LoggerFactory.getLogger(SessionPersistenceHook.class); + + private RuntimeContext runtimeContext; + + @Override + public void setRuntimeContext(RuntimeContext ctx) { + this.runtimeContext = ctx; + } + + @Override + public int priority() { + return 900; + } + + @Override + public Mono onEvent(T event) { + if (event instanceof PostCallEvent || event instanceof ErrorEvent) { + autoSave(event.getAgent()); + } + return Mono.just(event); + } + + private void autoSave(Agent agent) { + RuntimeContext ctx = this.runtimeContext; + if (ctx == null || ctx.getSession() == null || ctx.getSessionKey() == null) { + return; + } + if (agent instanceof StateModule sm) { + try { + sm.saveTo(ctx.getSession(), ctx.getSessionKey()); + log.debug("Auto-saved session state for agent '{}'", agent.getName()); + } catch (Exception e) { + log.warn( + "Auto-save session failed for agent '{}': {}", + agent.getName(), + e.getMessage()); + } + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/SubagentsHook.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/SubagentsHook.java new file mode 100644 index 000000000..b11519ed8 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/SubagentsHook.java @@ -0,0 +1,220 @@ +/* + * 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.hook; + +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PreReasoningEvent; +import io.agentscope.harness.agent.subagent.DefaultAgentManager; +import io.agentscope.harness.agent.subagent.SubagentFactory; +import io.agentscope.harness.agent.subagent.task.DefaultTaskRepository; +import io.agentscope.harness.agent.subagent.task.TaskRepository; +import io.agentscope.harness.agent.tool.AgentSpawnTool; +import io.agentscope.harness.agent.tool.TaskTool; +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import reactor.core.publisher.Mono; + +/** + * Hook that provides the managed subagent mechanism. + * + *

    In default mode (standalone {@code HarnessAgent}), this hook creates an + * {@link AgentSpawnTool} backed by a {@link DefaultAgentManager}. In session mode + * (orchestrated via {@code AgentBootstrap}), an external tool (typically {@code SessionsTool}) is + * injected, replacing the default {@link AgentSpawnTool}. + * + *

    Responsibilities: + * + *

      + *
    1. Registers the subagent tool and {@link TaskTool} as agent tools + *
    2. Injects rich subagent usage guidance into the unified system message at + * {@link PreReasoningEvent} time via {@link PreReasoningEvent#appendSystemContent}. + * Because each {@link PreReasoningEvent} starts from a fresh copy of the frozen base + * system message, calling {@code appendSystemContent} on every iteration is safe — + * content never accumulates across iterations. + *
    + */ +public class SubagentsHook implements Hook { + + // @formatter:off + private static final String SUBAGENT_SECTION_TEMPLATE = + """ + + ## Subagents + + You have access to subagent tools for spawning and coordinating isolated subagents. + Subagents are ephemeral — they live only for the duration of the task and return a single result. + + ### Agent Tools + + **`%s`** — Spawn an isolated subagent + - `agent_id` (required): which subagent to instantiate + - `task` (optional): initial prompt; omit to create a persistent session + - `label` (optional): human-readable name for referencing via send + - `timeout_seconds`: wait time; 0=fire-and-forget (returns task_id), default=30, max=600 + - Response always includes `agent_key:` (opaque handle) — save it for follow-up sends + + **`%s`** — Send a follow-up message to an existing subagent + - `agent_key`: copy the **full value** after `agent_key:` from spawn output (starts with `agent:`). This is NOT `agent_id`, NOT `session_id`, and NOT `task_id` + - Or use `label` if you set one at spawn (mutually exclusive with agent_key) + - `message` (required): content to send + - `timeout_seconds`: 0=fire-and-forget, >0=wait for reply (default: 30) + + **`%s`** — List active subagents + + ### Task Tools (for async/background operations) + + **`task_output`** — Retrieve the result of a background task by task_id. Supports blocking wait (default) or non-blocking peek (block=false). + + **`task_cancel`** — Cancel a running background task by task_id. No effect on already-completed tasks. + + **`task_list`** — List all background tasks with current statuses. Optionally filter by status (running, completed, failed, cancelled). + + ### Available agent ids + %s + + ### When to use subagents + - When a task is complex and multi-step, and can be fully delegated in isolation + - When a task is independent of other tasks and can run in parallel + - When a task requires focused reasoning or heavy context usage that would bloat the main thread + - When sandboxing improves reliability (e.g. code analysis, structured searches, data formatting) + - When you only care about the output, not the intermediate steps (e.g. research → synthesized report) + + ### When NOT to use subagents + - If the task is trivial (a few tool calls or simple lookup) + - If you need to see intermediate reasoning or steps after completion + - If delegating does not reduce token usage, complexity, or context switching + - If splitting would add latency without benefit + + ### Subagent lifecycle + 1. **Spawn** → Provide clear role, instructions, and expected output format + 2. **Run** → The subagent completes the task autonomously + 3. **Return** → The subagent provides a single structured result + 4. **Reconcile** → Incorporate or synthesize the result into the main thread + + ### Usage patterns + - **Parallel execution**: Launch multiple subagents concurrently with timeout_seconds=0 when tasks are independent, then collect results with task_output + - **Sync delegation**: Use default timeout for simple one-shot delegation + - **Persistent session**: Spawn without a task, then use send for multi-turn interaction + - **Cancel stale work**: Use task_cancel to stop background tasks that are no longer needed + - Subagent results are NOT visible to the user — always summarize them in your response + """; + // @formatter:on + + private final List entries; + private final Object subagentTool; + private final TaskTool taskTool; + private final boolean isSessionMode; + + /** + * Default mode: creates {@link AgentSpawnTool} + {@link DefaultAgentManager} internally. + * + * @param entries subagent descriptors (agent_id, description, factory) + * @param taskRepository background task store for async operations + * @param workspaceManager workspace accessor for session file path resolution + */ + public SubagentsHook( + List entries, + TaskRepository taskRepository, + WorkspaceManager workspaceManager) { + this.entries = List.copyOf(entries); + this.isSessionMode = false; + Map factories = buildFactories(entries); + DefaultAgentManager dam = new DefaultAgentManager(factories, workspaceManager); + TaskRepository repo = taskRepository != null ? taskRepository : new DefaultTaskRepository(); + this.subagentTool = new AgentSpawnTool(dam, repo, 0); + this.taskTool = new TaskTool(repo); + } + + /** + * Session mode: uses the externally provided tool (typically {@code SessionsTool}). + * + * @param entries subagent descriptors (for prompt injection — agent id listing) + * @param externalSubagentTool the external tool instance (e.g. SessionsTool) + * @param taskRepository background task store for async operations + */ + public SubagentsHook( + List entries, + Object externalSubagentTool, + TaskRepository taskRepository) { + this.entries = List.copyOf(entries); + this.isSessionMode = true; + this.subagentTool = externalSubagentTool; + TaskRepository repo = taskRepository != null ? taskRepository : new DefaultTaskRepository(); + this.taskTool = new TaskTool(repo); + } + + public SubagentsHook(List entries) { + this(entries, (TaskRepository) null, (WorkspaceManager) null); + } + + @Override + public List tools() { + if (entries.isEmpty()) { + return List.of(); + } + return List.of(subagentTool, taskTool); + } + + @Override + public Mono onEvent(T event) { + if (event instanceof PreReasoningEvent preReasoning) { + injectSubagentPrompt(preReasoning); + } + return Mono.just(event); + } + + @Override + public int priority() { + return 80; + } + + private void injectSubagentPrompt(PreReasoningEvent event) { + if (entries.isEmpty()) { + return; + } + String agentList = + entries.stream() + .map(e -> String.format("- `%s`: %s", e.name(), e.description())) + .collect(Collectors.joining("\n")); + + String spawnName = isSessionMode ? "sessions_spawn" : "agent_spawn"; + String sendName = isSessionMode ? "sessions_send" : "agent_send"; + String listName = isSessionMode ? "sessions_list" : "agent_list"; + + String section = + String.format(SUBAGENT_SECTION_TEMPLATE, spawnName, sendName, listName, agentList); + + event.appendSystemContent(section); + } + + private static Map buildFactories(List entries) { + Map factories = new HashMap<>(); + for (SubagentEntry entry : entries) { + factories.put(entry.name(), entry.factory()); + } + return factories; + } + + /** + * Descriptor for a subagent identified by agent id, with its description and {@link + * SubagentFactory}. + */ + public record SubagentEntry(String name, String description, SubagentFactory factory) {} +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/ToolResultEvictionHook.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/ToolResultEvictionHook.java new file mode 100644 index 000000000..f7c6e463f --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/ToolResultEvictionHook.java @@ -0,0 +1,202 @@ +/* + * 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.hook; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PostActingEvent; +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.harness.agent.filesystem.AbstractFilesystem; +import io.agentscope.harness.agent.filesystem.model.WriteResult; +import io.agentscope.harness.agent.memory.compaction.ToolResultEvictionConfig; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Hook that evicts oversized tool results to the {@link AbstractFilesystem} immediately after + * each tool call, before the result is stored in the agent's memory. + * + *

    When the text content of a {@link ToolResultBlock} exceeds + * {@link ToolResultEvictionConfig#getMaxResultChars()}, this hook: + *

      + *
    1. Writes the full result to + * {@code {evictionPath}/{agentName}/{sanitized-toolCallId}} in the filesystem.
    2. + *
    3. Replaces the in-context {@code ToolResultBlock} with a compact placeholder containing + * a head+tail preview and an instruction to use {@code readFile} for the full content.
    4. + *
    5. Calls {@link PostActingEvent#setToolResult} so downstream hooks and memory see only + * the placeholder.
    6. + *
    + * + *

    Independence from other context-management mechanisms: + *

      + *
    • This hook fires on {@link PostActingEvent} — once per tool call, triggered by + * individual result size (context width).
    • + *
    • Argument truncation runs inside {@link CompactionHook} + * at {@code PreReasoningEvent} — triggered by accumulated message count/tokens.
    • + *
    • Conversation compaction runs inside {@code CompactionHook} at + * {@code PreReasoningEvent} — triggered by overall conversation length (context depth).
    • + *
    + * Each mechanism evaluates its own independent condition; none depends on the others having run. + * + *

    Runs at priority 50, after {@link AgentTraceHook} (priority 0) so the original + * result size is logged before the placeholder replaces it. + * + *

    Tools listed in {@link ToolResultEvictionConfig#getExcludedToolNames()} are never evicted + * (e.g. {@code readFile} — evicting would cause re-read loops). + */ +public class ToolResultEvictionHook implements Hook { + + private static final RuntimeContext DEFAULT_FS_RUNTIME = RuntimeContext.empty(); + + private static final Logger log = LoggerFactory.getLogger(ToolResultEvictionHook.class); + + private final AbstractFilesystem filesystem; + private final ToolResultEvictionConfig config; + + public ToolResultEvictionHook(AbstractFilesystem filesystem, ToolResultEvictionConfig config) { + this.filesystem = filesystem; + this.config = config; + } + + @Override + public int priority() { + // After AgentTraceHook (0) — original result size is logged first, then replaced + return 50; + } + + @Override + public Mono onEvent(T event) { + if (!(event instanceof PostActingEvent postActing)) { + return Mono.just(event); + } + + ToolUseBlock toolUse = postActing.getToolUse(); + ToolResultBlock toolResult = postActing.getToolResult(); + + if (toolUse == null || toolResult == null) { + return Mono.just(event); + } + + String toolName = toolUse.getName(); + if (config.getExcludedToolNames().contains(toolName)) { + return Mono.just(event); + } + + String fullText = extractText(toolResult); + if (fullText.length() <= config.getMaxResultChars()) { + return Mono.just(event); + } + + String agentName = event.getAgent().getName(); + String toolCallId = toolUse.getId(); + String evictionPath = buildEvictionPath(agentName, toolCallId); + + try { + WriteResult writeResult = filesystem.write(DEFAULT_FS_RUNTIME, evictionPath, fullText); + if (!writeResult.isSuccess()) { + log.warn( + "[{}] Failed to evict tool result [tool={}, id={}]: {}", + agentName, + toolName, + toolCallId, + writeResult.error()); + return Mono.just(event); + } + + String placeholder = buildPlaceholder(fullText, evictionPath); + ToolResultBlock evicted = + new ToolResultBlock( + toolResult.getId(), + toolResult.getName(), + List.of(TextBlock.builder().text(placeholder).build()), + null); + + postActing.setToolResult(evicted); + + log.info( + "[{}] Evicted large tool result [tool={}, id={}, chars={} -> {}]", + agentName, + toolName, + toolCallId, + fullText.length(), + evictionPath); + } catch (Exception e) { + log.warn( + "[{}] Exception evicting tool result [tool={}, id={}]: {}", + agentName, + toolName, + toolCallId, + e.getMessage()); + } + + return Mono.just(event); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private String extractText(ToolResultBlock toolResult) { + if (toolResult.getOutput() == null) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (ContentBlock block : toolResult.getOutput()) { + if (block instanceof TextBlock tb && tb.getText() != null) { + sb.append(tb.getText()); + } + } + return sb.toString(); + } + + private String buildEvictionPath(String agentName, String toolCallId) { + String base = config.getEvictionPath(); + if (!base.startsWith("/")) { + base = "/" + base; + } + // Sanitize to filesystem-safe characters + String safeAgent = agentName.replaceAll("[^a-zA-Z0-9_-]", "_"); + String safeId = toolCallId.replaceAll("[^a-zA-Z0-9_-]", "_"); + return base + "/" + safeAgent + "/" + safeId; + } + + private String buildPlaceholder(String fullText, String evictionPath) { + int len = fullText.length(); + int pLen = Math.min(config.getPreviewChars(), len / 2); + + StringBuilder sb = new StringBuilder(); + sb.append( + String.format( + "Tool output was too large (%,d chars) and has been saved to `%s`.%n" + + "To read the full output, use `read_file` with path `%s`.%n%n", + len, evictionPath, evictionPath)); + + if (pLen > 0) { + sb.append(String.format("Preview (first %,d chars):%n", pLen)); + sb.append(fullText, 0, pLen); + sb.append(String.format("%n%n... and last %,d chars:%n", pLen)); + sb.append(fullText, len - pLen, len); + } + + return sb.toString(); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/WorkspaceContextHook.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/WorkspaceContextHook.java new file mode 100644 index 000000000..581efb6d0 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/hook/WorkspaceContextHook.java @@ -0,0 +1,295 @@ +/* + * 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.hook; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.hook.Hook; +import io.agentscope.core.hook.HookEvent; +import io.agentscope.core.hook.PreCallEvent; +import io.agentscope.core.hook.RuntimeContextAware; +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.nio.file.Path; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import reactor.core.publisher.Mono; + +/** + * Injects workspace context (session info, AGENTS.md, MEMORY.md, knowledge) into the unified + * system message on {@link PreCallEvent}. + * + *

    Workspace content is added via {@link PreCallEvent#appendSystemContent}. + * Because this hook fires only on {@link PreCallEvent} (once per {@code call()}), there + * is no risk of accumulation across reasoning iterations. + * + *

    Runs at priority 900 — after all other pre-call hooks so that workspace context is + * appended after skill and subagent guidance. + */ +public class WorkspaceContextHook implements Hook, RuntimeContextAware { + + private static final String SESSION_CONTEXT_SECTION_TEMPLATE = + """ + ## Session Context + This is the %s. We are setting up the context for our chat. + Today's date is %s. + My operating system is: %s + The workspace directory is: %s + The project's temporary directory is: %s + %s + """; + + private static final String WORKSPACE_GUIDANCE_TEMPLATE = + """ + ## Domain Knowledge + The workspace `knowledge/` tree holds many detailed reference documents (not only a single summary file). When the task needs specs, procedures, schemas, or domain facts, treat that directory as the source of truth. + Below, `` already includes what you need to navigate it: injected `knowledge/KNOWLEDGE.md` (if present) plus a **full list of knowledge file paths** under `knowledge/` — use that as the catalog of what exists and where. + For content not inlined here, open only the paths you need with read_file, grep, or glob (prefer targeted reads over loading entire trees into the reply). + + ## Memory Recall + Before answering questions about prior work, decisions, dates, people, or preferences: \ + run memory_search on MEMORY.md + memory/*.md, then memory_get for needed lines. \ + Include Source: citations when helpful. + + ## Memory Persistence + You have a persistent MEMORY.md. Update it proactively when: + - User shares preferences, project context, or decisions + - Important outcomes or action items are established + Use edit_file/write_file to append concise bullet points. \ + Do NOT duplicate existing entries. \ + Memory is also automatically extracted at conversation end. + + ## Workspace + Your working directory is: %s + Treat this directory as the single global workspace for file operations unless explicitly instructed otherwise. + AGENTS.md define persona and local conventions — honor them when consistent with safety and policy. + + ## Workspace Files (Injected) + The following was loaded in from files in your workspace. + These files (for example, `AGENTS.md`, `MEMORY.md`, and `knowledge/KNOWLEDGE.md`) contain memory, facts, preferences, guidelines, and user-specific details learned from prior interactions with user. + """; + + private static final String TRUNCATION_NOTICE = + "\n\n... (memory truncated — use memory_search for older entries) ...\n"; + + private static final int DEFAULT_MAX_CONTEXT_TOKENS = 8000; + + private final WorkspaceManager workspaceManager; + private final String agentName; + private final String environmentMemory; + private final int maxContextTokens; + private List additionalContextFiles = List.of(); + private RuntimeContext runtimeContext; + + public WorkspaceContextHook(WorkspaceManager workspaceManager) { + this(workspaceManager, "HarnessAgent", null, DEFAULT_MAX_CONTEXT_TOKENS); + } + + public WorkspaceContextHook(WorkspaceManager workspaceManager, int maxContextTokens) { + this(workspaceManager, "HarnessAgent", null, maxContextTokens); + } + + public WorkspaceContextHook( + WorkspaceManager workspaceManager, + String agentName, + String environmentMemory, + int maxContextTokens) { + this.workspaceManager = workspaceManager; + this.agentName = agentName != null && !agentName.isBlank() ? agentName : "HarnessAgent"; + this.environmentMemory = environmentMemory; + this.maxContextTokens = maxContextTokens; + } + + public void setAdditionalContextFiles(List files) { + this.additionalContextFiles = files != null ? files : List.of(); + } + + @Override + public void setRuntimeContext(RuntimeContext runtimeContext) { + this.runtimeContext = runtimeContext; + } + + @Override + public Mono onEvent(T event) { + if (event instanceof PreCallEvent preCallEvent) { + injectWorkspaceContext(preCallEvent); + } + return Mono.just(event); + } + + @Override + public int priority() { + return 900; + } + + private void injectWorkspaceContext(PreCallEvent event) { + String agentsContent = workspaceManager.readAgentsMd().strip(); + String memoryContent = workspaceManager.readMemoryMd().strip(); + String knowledgeContent = workspaceManager.readKnowledgeMd().strip(); + Path workspace = workspaceManager.getWorkspace(); + String sessionContext = buildSessionContextSection(workspace); + + String knowledgeBlock = buildKnowledgeBlock(knowledgeContent, workspace); + String additionalBlock = buildAdditionalContextBlock(); + + int fixedTokens = + estimateTokens(sessionContext) + + estimateTokens(agentsContent) + + estimateTokens(knowledgeBlock) + + estimateTokens(additionalBlock); + int memoryTokens = estimateTokens(memoryContent); + int available = maxContextTokens - fixedTokens; + if (available > 0 && memoryTokens > available) { + memoryContent = truncateToTokenBudget(memoryContent, available); + } + + String guidance = String.format(WORKSPACE_GUIDANCE_TEMPLATE, workspace.toAbsolutePath()); + String loadedContext = + buildLoadedContextSection( + agentsContent, memoryContent, knowledgeBlock, additionalBlock); + String section = buildWorkspaceSection(sessionContext, guidance, loadedContext); + + event.appendSystemContent(section); + } + + private String buildWorkspaceSection( + String sessionContext, String guidance, String loadedContextSection) { + StringBuilder sb = new StringBuilder(); + if (!sessionContext.isBlank()) { + sb.append(sessionContext).append("\n\n"); + } + sb.append(guidance).append("\n").append(loadedContextSection); + return sb.toString(); + } + + private String buildSessionContextSection(Path workspace) { + String today = LocalDate.now().format(DateTimeFormatter.ofPattern("EEEE MMM d, yyyy")); + String platform = System.getProperty("os.name") + " " + System.getProperty("os.version"); + String tempDir = System.getProperty("java.io.tmpdir"); + String dynamicPart = buildSessionDynamicPart(); + + return String.format( + SESSION_CONTEXT_SECTION_TEMPLATE, + agentName, + today, + platform, + workspace.toAbsolutePath(), + tempDir, + dynamicPart) + .strip(); + } + + private String buildSessionDynamicPart() { + List parts = new ArrayList<>(); + if (runtimeContext != null && runtimeContext.getSessionId() != null) { + parts.add("Session ID: " + runtimeContext.getSessionId()); + } + if (environmentMemory != null && !environmentMemory.isBlank()) { + parts.add(environmentMemory); + } + return parts.isEmpty() ? "" : String.join("\n", parts); + } + + /** + * Builds XML-style loaded context blocks for AGENTS/MEMORY/KNOWLEDGE and extra files. + */ + private String buildLoadedContextSection( + String agentsContent, + String memoryContent, + String knowledgeBlock, + String additionalBlock) { + StringBuilder sb = new StringBuilder(); + sb.append("\n"); + sb.append(buildXmlContext("agents_context", agentsContent)); + sb.append(buildXmlContext("memory_context", memoryContent)); + sb.append(buildXmlContext("domain_knowledge_context", knowledgeBlock)); + if (!additionalBlock.isBlank()) { + sb.append(additionalBlock); + } + sb.append("\n"); + return sb.toString(); + } + + private static String buildXmlContext(String tagName, String content) { + if (content == null || content.isBlank()) { + return " <" + tagName + ">\n"; + } + return " <" + tagName + ">\n" + indentByTwo(content.strip()) + "\n \n"; + } + + private static String indentByTwo(String text) { + return text.lines().map(line -> " " + line).collect(Collectors.joining("\n")); + } + + /** + * Renders additional user-configured files as XML blocks under {@code }. + */ + private String buildAdditionalContextBlock() { + if (additionalContextFiles.isEmpty()) { + return ""; + } + StringBuilder sb = new StringBuilder(); + for (String relPath : additionalContextFiles) { + String content = workspaceManager.readManagedWorkspaceFileUtf8(relPath); + if (content != null && !content.isBlank()) { + String tag = relPath.replace("/", "_").replace(".", "_").toLowerCase(); + sb.append(" <").append(tag).append(">\n"); + sb.append(indentByTwo(content.strip())).append("\n"); + sb.append(" \n"); + } + } + return sb.toString(); + } + + /** + * Estimates token count using the chars/4 heuristic (consistent with pi-mono). + */ + private static int estimateTokens(String text) { + return text == null || text.isEmpty() ? 0 : text.length() / 4; + } + + private static String truncateToTokenBudget(String text, int maxTokens) { + int maxChars = maxTokens * 4; + if (text.length() <= maxChars) { + return text; + } + return text.substring(0, maxChars) + TRUNCATION_NOTICE; + } + + private String buildKnowledgeBlock(String knowledgeContent, Path workspace) { + List knowledgeFiles = workspaceManager.listKnowledgeFiles(); + StringBuilder sb = new StringBuilder(); + + if (!knowledgeContent.isBlank()) { + sb.append(knowledgeContent.strip()).append("\n"); + } + + if (!knowledgeFiles.isEmpty()) { + if (sb.length() > 0) { + sb.append("\n"); + } + sb.append("Knowledge files:\n"); + sb.append( + knowledgeFiles.stream() + .map(f -> "- " + workspace.relativize(f)) + .collect(Collectors.joining("\n"))); + sb.append("\n"); + } + + return sb.toString(); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/MemoryConsolidator.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/MemoryConsolidator.java new file mode 100644 index 000000000..324d6726a --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/MemoryConsolidator.java @@ -0,0 +1,291 @@ +/* + * 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.memory; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.Model; +import io.agentscope.harness.agent.filesystem.AbstractFilesystem; +import io.agentscope.harness.agent.filesystem.model.FileInfo; +import io.agentscope.harness.agent.filesystem.model.GlobResult; +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * LLM-based consolidation of daily memory ledgers into the curated {@code MEMORY.md}. + * + *

    This component owns the second layer of the two-layer memory model: + *

      + *
    • Layer 1 — daily ledger: {@code memory/YYYY-MM-DD.md} files written by + * {@link MemoryFlushManager}, append-only, one section per compaction flush.
    • + *
    • Layer 2 — curated MEMORY.md: Owned by this class. Periodically reads the + * daily ledgers (those modified since the last consolidation watermark) plus the + * current MEMORY.md, asks the LLM to merge / dedupe / trim, and overwrites + * MEMORY.md with the result.
    • + *
    + * + *

    A small state file ({@code memory/.consolidation_state}) records the timestamp of + * the last successful consolidation. Daily files whose {@code modifiedAt} is at or before + * that timestamp are skipped — reducing token usage and protecting MEMORY.md from being + * re-rewritten with stale content. + * + *

    All file I/O is performed via the {@link AbstractFilesystem} obtained from the + * {@link WorkspaceManager}, so this class is backend-agnostic (works with Local, + * Sandbox, and Remote filesystems without any direct {@code java.nio.file.Files} usage). + */ +public class MemoryConsolidator { + + private static final RuntimeContext DEFAULT_FS_RUNTIME = RuntimeContext.empty(); + + private static final Logger log = LoggerFactory.getLogger(MemoryConsolidator.class); + + /** Hidden state file inside {@code memory/} tracking the last consolidation Instant. */ + public static final String STATE_FILE = ".consolidation_state"; + + private static final String CONSOLIDATION_PROMPT = + """ + You are a memory consolidation assistant. You own the curated long-term memory \ + file MEMORY.md. Your job is to merge new daily ledger entries into MEMORY.md while \ + keeping it concise, deduplicated, and high-signal. + + You are given two inputs: + 1. The current MEMORY.md content (the existing curated long-term memory). + 2. New daily ledger entries that have been appended since the last consolidation. + + Rules: + - MEMORY.md is the single source of truth for cross-day, cross-session knowledge. \ + Keep it stable and authoritative. + - Daily ledger entries are stream-of-consciousness flush logs — they may be noisy, \ + redundant with MEMORY.md, or redundant with each other. Promote only what is \ + durable and reusable. + - Deduplicate: if a new entry restates something MEMORY.md already covers, skip it. + - Merge related facts: combine entries about the same topic into cohesive paragraphs \ + with clear section headers. + - Update or remove stale information when new entries supersede it. + - Keep total output within %d tokens (approximately %d characters); prioritize \ + recent and frequently-referenced information when trimming. + + Output the COMPLETE new MEMORY.md content (not just a diff). Use markdown.\ + """; + + private final WorkspaceManager workspaceManager; + private final Model model; + private final int maxMemoryTokens; + + public MemoryConsolidator(WorkspaceManager workspaceManager, Model model) { + this(workspaceManager, model, 4000); + } + + public MemoryConsolidator(WorkspaceManager workspaceManager, Model model, int maxMemoryTokens) { + this.workspaceManager = workspaceManager; + this.model = model; + this.maxMemoryTokens = maxMemoryTokens; + } + + /** + * Runs consolidation: reads daily files modified after the last watermark and the + * current MEMORY.md, uses the LLM to merge them, overwrites MEMORY.md, and + * advances the watermark on success. + * + *

    If no daily files have been touched since the last run, this is a no-op. + */ + public Mono consolidate() { + Instant watermark = readWatermark(); + Instant runStart = Instant.now(); + + String currentMemory = workspaceManager.readMemoryMd(); + String dailyEntries = readDailyEntries(watermark); + + if (dailyEntries.isBlank()) { + log.debug("No fresh daily entries since {} — skipping consolidation", watermark); + return Mono.empty(); + } + + int maxChars = maxMemoryTokens * 4; + String systemPrompt = String.format(CONSOLIDATION_PROMPT, maxMemoryTokens, maxChars); + + StringBuilder userContent = new StringBuilder(); + userContent.append("Current MEMORY.md:\n"); + userContent.append(currentMemory.isBlank() ? "(empty)" : currentMemory); + userContent + .append("\n\nNew daily ledger entries to merge") + .append(watermark == Instant.EPOCH ? "" : " (since " + watermark + ")") + .append(":\n"); + userContent.append(dailyEntries); + + List messages = new ArrayList<>(); + messages.add( + Msg.builder() + .role(MsgRole.SYSTEM) + .content(TextBlock.builder().text(systemPrompt).build()) + .build()); + messages.add( + Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text(userContent.toString()).build()) + .build()); + + return model.stream(messages, null, null) + .reduce( + new StringBuilder(), + (sb, chatResponse) -> { + if (chatResponse.getContent() != null) { + for (var block : chatResponse.getContent()) { + if (block instanceof TextBlock tb && tb.getText() != null) { + sb.append(tb.getText()); + } + } + } + return sb; + }) + .flatMap( + sb -> { + String consolidated = sb.toString().strip(); + if (consolidated.isBlank()) { + log.warn("Consolidation produced empty output, skipping"); + return Mono.empty(); + } + writeConsolidatedMemory(consolidated); + writeWatermark(runStart); + log.info( + "MEMORY.md consolidated ({} chars), watermark advanced to {}", + consolidated.length(), + runStart); + return Mono.empty(); + }); + } + + /** + * Reads daily memory files modified strictly after the given watermark. + * If watermark is {@link Instant#EPOCH}, all daily files are returned (first run). + * + *

    All I/O is done through the {@link AbstractFilesystem} so this works equally well + * with Local, Sandbox, and Store backends. + */ + private String readDailyEntries(Instant watermark) { + AbstractFilesystem fs = workspaceManager.getFilesystem(); + if (fs == null) { + return ""; + } + + GlobResult glob = fs.glob(DEFAULT_FS_RUNTIME, "*.md", "memory"); + if (!glob.isSuccess() || glob.matches() == null || glob.matches().isEmpty()) { + return ""; + } + + List eligible = new ArrayList<>(); + for (FileInfo fi : glob.matches()) { + if (fi.isDirectory()) { + continue; + } + String name = fileName(fi.path()); + if (name.equals(STATE_FILE) || name.equals("archive") || !name.endsWith(".md")) { + continue; + } + if (isModifiedAfter(fi, watermark)) { + eligible.add(fi); + } + } + eligible.sort(Comparator.comparing(fi -> fileName(fi.path()))); + + StringBuilder sb = new StringBuilder(); + for (FileInfo fi : eligible) { + String rel = toRelative(fi.path()); + String content = workspaceManager.readManagedWorkspaceFileUtf8(rel); + if (content != null && !content.isBlank()) { + sb.append("### ").append(fileName(fi.path())).append("\n"); + sb.append(content.strip()).append("\n\n"); + } + } + return sb.toString(); + } + + private static boolean isModifiedAfter(FileInfo fi, Instant watermark) { + String modifiedAt = fi.modifiedAt(); + if (modifiedAt == null || modifiedAt.isBlank()) { + return true; // be safe — include on unknown mtime + } + try { + return Instant.parse(modifiedAt).isAfter(watermark); + } catch (Exception e) { + return true; // be safe on parse error + } + } + + /** Extracts the file name (last path segment) from a path string. */ + private static String fileName(String path) { + if (path == null || path.isEmpty()) { + return ""; + } + String stripped = path.endsWith("/") ? path.substring(0, path.length() - 1) : path; + int idx = stripped.lastIndexOf('/'); + return idx >= 0 ? stripped.substring(idx + 1) : stripped; + } + + /** + * Converts an absolute filesystem path (e.g. {@code /memory/2025-01-01.md}) to a + * workspace-relative path ({@code memory/2025-01-01.md}) for use with + * {@link WorkspaceManager#readManagedWorkspaceFileUtf8}. + */ + private static String toRelative(String path) { + if (path == null) { + return ""; + } + return path.startsWith("/") ? path.substring(1) : path; + } + + private void writeConsolidatedMemory(String content) { + workspaceManager.writeUtf8WorkspaceRelative("MEMORY.md", content); + } + + static final String STATE_REL_PATH = "memory/" + STATE_FILE; + + /** Reads the last consolidation Instant, or {@link Instant#EPOCH} if none recorded. */ + Instant readWatermark() { + try { + String value = workspaceManager.readManagedWorkspaceFileUtf8(STATE_REL_PATH); + if (value == null || value.isBlank()) { + return Instant.EPOCH; + } + return Instant.parse(value.strip()); + } catch (Exception e) { + log.warn( + "Failed to read consolidation watermark at {}: {} — treating as EPOCH", + STATE_REL_PATH, + e.getMessage()); + return Instant.EPOCH; + } + } + + private void writeWatermark(Instant ts) { + try { + workspaceManager.writeUtf8WorkspaceRelative(STATE_REL_PATH, ts.toString()); + } catch (Exception e) { + log.warn( + "Failed to write consolidation watermark at {}: {}", + STATE_REL_PATH, + e.getMessage()); + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/MemoryFlushManager.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/MemoryFlushManager.java new file mode 100644 index 000000000..697f1f2e0 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/MemoryFlushManager.java @@ -0,0 +1,393 @@ +/* + * 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.memory; + +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.Model; +import io.agentscope.core.util.JsonUtils; +import io.agentscope.harness.agent.memory.session.SessionEntry; +import io.agentscope.harness.agent.memory.session.SessionTree; +import io.agentscope.harness.agent.workspace.WorkspaceConstants; +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.nio.file.Path; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + * Manages memory flush operations: extracting long-term memories from a conversation + * window and appending them to today's daily memory ledger. + * + *

    Two-layer memory model (this class owns only the first layer): + *

      + *
    • {@code memory/YYYY-MM-DD.md} — append-only daily ledger. Each compaction's flush + * appends a timestamped section here. Written ONLY by this class.
    • + *
    • {@code MEMORY.md} — globally curated, deduplicated, size-bounded long-term memory. + * Written ONLY by {@link MemoryConsolidator} on a periodic schedule. Treated as + * read-only context here.
    • + *
    + */ +public class MemoryFlushManager { + + private static final Logger log = LoggerFactory.getLogger(MemoryFlushManager.class); + + private static final String FLUSH_SYSTEM_PROMPT = + """ + You are a memory extraction assistant. Analyze the conversation below and extract \ + important facts, decisions, preferences, and contextual information that should be \ + remembered for future conversations. + + Output ONLY the extracted memories as a markdown bullet list. Each item should be \ + a concise, self-contained fact. Include dates, names, and specifics when available. + + If there is nothing worth remembering, respond with exactly: NO_REPLY + + Guidelines: + - Extract user preferences, personal information, project decisions + - Capture important technical decisions and their rationale + - Note any commitments, deadlines, or action items + - Record relationship context (who works on what, team structure) + - Ignore routine greetings, tool invocations, and ephemeral status updates + + IMPORTANT — write target and append-only rules: + - You are writing to TODAY'S daily memory ledger (memory/YYYY-MM-DD.md), NOT to \ + MEMORY.md. The daily ledger is append-only — your output will be appended after the \ + entries already shown below. + - MEMORY.md is the curated long-term memory and is shown ONLY as read-only context. \ + Do NOT restate facts already covered by MEMORY.md or by today's earlier entries; a \ + separate consolidation step periodically merges new daily entries into MEMORY.md. + - Keep each bullet point independent and self-contained so entries can be searched \ + individually.\ + """; + + private final WorkspaceManager workspaceManager; + private final Model model; + + public MemoryFlushManager(WorkspaceManager workspaceManager, Model model) { + this.workspaceManager = workspaceManager; + this.model = model; + } + + /** + * Extracts long-term memories from messages using the model and writes them to disk. + * + *

    Provides existing MEMORY.md and today's daily file content to the extraction LLM + * so it can effectively deduplicate and avoid re-extracting known facts. + */ + public Mono flushMemories(List messages) { + String conversationText = serializeMessages(messages); + if (conversationText.isBlank()) { + return Mono.empty(); + } + + String existingMemory = readExistingContent(WorkspaceConstants.MEMORY_MD); + String today = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE); + String dailyRelPath = WorkspaceConstants.MEMORY_DIR + "/" + today + ".md"; + String existingDaily = readExistingContent(dailyRelPath); + + StringBuilder userPrompt = new StringBuilder(); + if (!existingMemory.isBlank()) { + userPrompt + .append("MEMORY.md (read-only curated long-term memory — do NOT restate):\n") + .append(existingMemory) + .append("\n\n"); + } + if (!existingDaily.isBlank()) { + userPrompt + .append("Today's daily ledger so far (your output will be appended after):\n") + .append(existingDaily) + .append("\n\n"); + } + userPrompt + .append( + "Extract NEW memories from this conversation window (skip anything" + + " already covered above):\n\n") + .append(conversationText); + + List flushInput = new ArrayList<>(); + flushInput.add( + Msg.builder() + .role(MsgRole.SYSTEM) + .content(TextBlock.builder().text(FLUSH_SYSTEM_PROMPT).build()) + .build()); + flushInput.add( + Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text(userPrompt.toString()).build()) + .build()); + + return model.stream(flushInput, null, null) + .reduce( + new StringBuilder(), + (sb, chatResponse) -> { + List blocks = chatResponse.getContent(); + if (blocks != null) { + for (ContentBlock block : blocks) { + if (block instanceof TextBlock tb) { + String t = tb.getText(); + if (t != null) { + sb.append(t); + } + } + } + } + return sb; + }) + .flatMap( + sb -> { + String extracted = sb.toString(); + if (extracted.isBlank() || extracted.strip().equals("NO_REPLY")) { + log.debug("No memories to flush"); + return Mono.empty(); + } + writeMemoryFiles(extracted); + return Mono.empty(); + }); + } + + /** + * Returns the string path of the session JSONL file where messages for the given agent and + * session are offloaded. Used by the compaction layer to embed the archive location in the + * summary message so the agent can retrieve full history if needed. + */ + public String resolveOffloadPath(String agentId, String sessionId) { + try { + Path p = workspaceManager.resolveSessionContextFile(agentId, sessionId); + return p != null ? p.toString() : ""; + } catch (Exception e) { + log.debug( + "Could not resolve offload path for agent={}, session={}: {}", + agentId, + sessionId, + e.getMessage()); + return ""; + } + } + + /** + * Offloads raw messages to the JSONL session tree. + */ + public void offloadMessages(List messages, String agentId, String sessionId) { + offloadToSessionTree(messages, agentId, sessionId); + + log.debug( + "Offloaded {} messages for agent={}, session={}", + messages.size(), + agentId, + sessionId); + workspaceManager.updateSessionIndex(agentId, sessionId, "conversation offloaded"); + } + + private void offloadToSessionTree(List messages, String agentId, String sessionId) { + try { + Path contextFile = workspaceManager.resolveSessionContextFile(agentId, sessionId); + SessionTree tree = + new SessionTree( + contextFile, + workspaceManager.getWorkspace(), + workspaceManager.getFilesystem()); + tree.load(); + // Sync from remote before appending so that entries written by a previous replica + // (cross-machine handoff) are included in the merged file pushed to remote. + tree.syncFromRemote(); + + String lastId = null; + for (Msg msg : messages) { + if (msg.getRole() == null || isSessionContextMessage(msg)) { + continue; + } + String rendered = renderContentBlocks(msg); + if (rendered == null || rendered.isBlank()) { + continue; + } + String toolCallId = extractToolCallId(msg); + SessionEntry.MessageEntry entry = + new SessionEntry.MessageEntry( + null, lastId, null, msg.getRole().name(), rendered, toolCallId); + tree.append(entry); + lastId = entry.getId(); + } + + tree.flush(); + } catch (Exception e) { + log.warn("Failed to offload to JSONL session tree: {}", e.getMessage()); + } + } + + /** + * Extracts a representative tool call ID from a message, if present. + * For TOOL messages, returns the first ToolResultBlock's id. + * For ASSISTANT messages with tool calls, returns the first ToolUseBlock's id. + */ + private static String extractToolCallId(Msg msg) { + for (ContentBlock block : msg.getContent()) { + if (block instanceof ToolResultBlock tr && tr.getId() != null) { + return tr.getId(); + } + if (block instanceof ToolUseBlock tu && tu.getId() != null) { + return tu.getId(); + } + } + return null; + } + + /** + * Appends the extracted entries to today's daily memory ledger. + * + *

    MEMORY.md is intentionally NOT touched here — it is owned by + * {@link MemoryConsolidator}, which periodically merges the daily ledgers into a + * curated, size-bounded MEMORY.md. + */ + private void writeMemoryFiles(String content) { + String today = LocalDate.now().format(DateTimeFormatter.ISO_LOCAL_DATE); + + String dailyEntry = + String.format( + "\n## Memory Flush — %s\n%s\n", + java.time.Instant.now().toString(), content); + + String dailyRelPath = WorkspaceConstants.MEMORY_DIR + "/" + today + ".md"; + workspaceManager.appendUtf8WorkspaceRelative(dailyRelPath, dailyEntry); + } + + private String readExistingContent(String relativePath) { + try { + String content = workspaceManager.readManagedWorkspaceFileUtf8(relativePath); + return content != null ? content : ""; + } catch (Exception e) { + log.debug("Could not read {}: {}", relativePath, e.getMessage()); + return ""; + } + } + + private static final String SESSION_CONTEXT_TAG = ""; + + /** + * Serializes all messages into a textual representation for the memory extraction model. + * Includes USER, ASSISTANT, and TOOL messages. Assistant tool-call blocks and tool-result + * blocks are rendered as concise text so the model can extract memories from tool interactions. + * The injected {@code } user message is skipped as it contains only + * environment metadata, not real conversation content. + */ + private String serializeMessages(List messages) { + return messages.stream() + .filter(m -> m.getRole() != null && m.getRole() != MsgRole.SYSTEM) + .filter(m -> !isSessionContextMessage(m)) + .map(this::renderMessage) + .filter(s -> s != null && !s.isBlank()) + .collect(Collectors.joining("\n")); + } + + private static boolean isSessionContextMessage(Msg msg) { + if (msg.getRole() != MsgRole.USER) { + return false; + } + String text = msg.getTextContent(); + return text != null && text.contains(SESSION_CONTEXT_TAG); + } + + private String renderMessage(Msg msg) { + String body = renderContentBlocks(msg); + if (body == null) { + return null; + } + return "[" + msg.getRole().name() + "]: " + body; + } + + /** + * Renders all content blocks of a message into a single text string. + * Returns null if no renderable content is found. + */ + private String renderContentBlocks(Msg msg) { + List blocks = msg.getContent(); + if (blocks == null || blocks.isEmpty()) { + return null; + } + + List parts = new ArrayList<>(); + for (ContentBlock block : blocks) { + if (block instanceof TextBlock tb) { + String text = tb.getText(); + if (text != null && !text.isBlank()) { + parts.add(text); + } + } else if (block instanceof ToolUseBlock tu) { + parts.add(renderToolUse(tu)); + } else if (block instanceof ToolResultBlock tr) { + parts.add(renderToolResult(tr)); + } + } + + if (parts.isEmpty()) { + return null; + } + return String.join("\n", parts); + } + + private static String renderToolUse(ToolUseBlock tu) { + StringBuilder sb = new StringBuilder(); + sb.append("[tool_call: ").append(tu.getName()); + if (tu.getInput() != null && !tu.getInput().isEmpty()) { + try { + String inputJson = JsonUtils.getJsonCodec().toJson(tu.getInput()); + if (inputJson.length() > 500) { + inputJson = inputJson.substring(0, 500) + "..."; + } + sb.append("(").append(inputJson).append(")"); + } catch (Exception e) { + sb.append("(...)"); + } + } + sb.append("]"); + return sb.toString(); + } + + private static String renderToolResult(ToolResultBlock tr) { + StringBuilder sb = new StringBuilder(); + sb.append("[tool_result"); + if (tr.getName() != null) { + sb.append(": ").append(tr.getName()); + } + sb.append("] "); + + List outputs = tr.getOutput(); + if (outputs != null) { + for (ContentBlock out : outputs) { + if (out instanceof TextBlock tb) { + String text = tb.getText(); + if (text != null) { + if (text.length() > 1000) { + sb.append(text, 0, 1000).append("...(truncated)"); + } else { + sb.append(text); + } + } + } + } + } + return sb.toString(); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/compaction/CompactionConfig.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/compaction/CompactionConfig.java new file mode 100644 index 000000000..d7dcd67a6 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/compaction/CompactionConfig.java @@ -0,0 +1,342 @@ +/* + * 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.memory.compaction; + +/** + * Configuration for conversation compaction (summarization). + * + *

      + *
    • trigger — when to run compaction (by token count or message count)
    • + *
    • keep — how many recent messages to preserve verbatim after compaction
    • + *
    + * + *

    Defaults: + *

      + *
    • Trigger at 50 messages or 80 000 estimated tokens (whichever comes first)
    • + *
    • Keep the 20 most recent messages verbatim
    • + *
    • Summarization is enabled; memory flush and offload are both enabled before summary
    • + *
    + */ +public class CompactionConfig { + + /** Default summary prompt with structured format. */ + public static final String DEFAULT_SUMMARY_PROMPT = + """ + + Context Extraction Assistant + + + + Your sole objective in this task is to extract the highest quality/most relevant \ + context from the conversation history below. + + + + You're nearing the total number of input tokens you can accept, so you must extract \ + the highest quality/most relevant pieces of information from your conversation history. + This context will then overwrite the conversation history presented below. Because of \ + this, ensure the context you extract is only the most important information to \ + continue working toward your overall goal. + + + + The conversation history below will be replaced with the context you extract in this \ + step. You want to ensure that you don't repeat any actions you've already completed, \ + so the context you extract from the conversation history should be focused on the \ + most important information to your overall goal. + + Structure your summary using these sections (populate each or write "None"): + + ## SESSION INTENT + What is the user's primary goal or request? + + ## SUMMARY + The most important context, decisions, reasoning, and rejected options. + + ## ARTIFACTS + Files or resources created, modified, or accessed (with specific paths and changes). + + ## NEXT STEPS + Specific tasks remaining to achieve the session intent. + + + Carefully read through the entire conversation history below and extract the most \ + important context. Respond ONLY with the extracted context. + + + {messages} + \ + """; + + private final int triggerMessages; + private final int triggerTokens; + private final int keepMessages; + private final int keepTokens; + private final String summaryPrompt; + private final boolean flushBeforeCompact; + private final boolean offloadBeforeCompact; + private final TruncateArgsConfig truncateArgsConfig; + + private CompactionConfig(Builder b) { + this.triggerMessages = b.triggerMessages; + this.triggerTokens = b.triggerTokens; + this.keepMessages = b.keepMessages; + this.keepTokens = b.keepTokens; + this.summaryPrompt = b.summaryPrompt; + this.flushBeforeCompact = b.flushBeforeCompact; + this.offloadBeforeCompact = b.offloadBeforeCompact; + this.truncateArgsConfig = b.truncateArgsConfig; + } + + /** Message count above which compaction is triggered (0 = disabled). */ + public int getTriggerMessages() { + return triggerMessages; + } + + /** Estimated token count above which compaction is triggered (0 = disabled). */ + public int getTriggerTokens() { + return triggerTokens; + } + + /** + * Number of recent conversation messages (non-SYSTEM) to preserve verbatim. + * Used when {@link #getKeepTokens()} is 0. + */ + public int getKeepMessages() { + return keepMessages; + } + + /** + * Token budget for the preserved tail. When non-zero, the keep boundary is determined by + * scanning from the end until the token budget is exhausted rather than by a fixed count. + */ + public int getKeepTokens() { + return keepTokens; + } + + /** Prompt template used for the summarization LLM call. Must contain {@code {messages}}. */ + public String getSummaryPrompt() { + return summaryPrompt; + } + + /** Whether to flush long-term memories from the prefix before compaction. */ + public boolean isFlushBeforeCompact() { + return flushBeforeCompact; + } + + /** Whether to offload raw messages to the session JSONL before compaction. */ + public boolean isOffloadBeforeCompact() { + return offloadBeforeCompact; + } + + /** + * Configuration for the lightweight pre-summarization argument truncation pass. + * When {@code null}, argument truncation is disabled. + */ + public TruncateArgsConfig getTruncateArgsConfig() { + return truncateArgsConfig; + } + + public static Builder builder() { + return new Builder(); + } + + public static class Builder { + + private int triggerMessages = 50; + private int triggerTokens = 80_000; + private int keepMessages = 20; + private int keepTokens = 0; + private String summaryPrompt = DEFAULT_SUMMARY_PROMPT; + private boolean flushBeforeCompact = true; + private boolean offloadBeforeCompact = true; + private TruncateArgsConfig truncateArgsConfig = null; + + /** Trigger compaction when conversation has at least this many messages (0 = disabled). */ + public Builder triggerMessages(int triggerMessages) { + this.triggerMessages = triggerMessages; + return this; + } + + /** Trigger compaction when estimated token count exceeds this value (0 = disabled). */ + public Builder triggerTokens(int triggerTokens) { + this.triggerTokens = triggerTokens; + return this; + } + + /** Number of recent messages to keep verbatim after compaction. */ + public Builder keepMessages(int keepMessages) { + this.keepMessages = keepMessages; + return this; + } + + /** + * Token budget for the preserved tail (overrides {@code keepMessages} when non-zero). + */ + public Builder keepTokens(int keepTokens) { + this.keepTokens = keepTokens; + return this; + } + + /** Custom summary prompt. Must contain {@code {messages}} placeholder. */ + public Builder summaryPrompt(String summaryPrompt) { + this.summaryPrompt = summaryPrompt; + return this; + } + + /** Whether to flush long-term memories before compaction (default true). */ + public Builder flushBeforeCompact(boolean flushBeforeCompact) { + this.flushBeforeCompact = flushBeforeCompact; + return this; + } + + /** Whether to offload raw messages to session JSONL before compaction (default true). */ + public Builder offloadBeforeCompact(boolean offloadBeforeCompact) { + this.offloadBeforeCompact = offloadBeforeCompact; + return this; + } + + /** + * Enables lightweight pre-summarization argument truncation. When set, large + * {@code ToolUseBlock} argument values in old messages are shortened before every + * model call (no LLM required). This fires at a separate, typically lower threshold + * than full compaction. + * + *

    Pass {@code null} (default) to disable. + */ + public Builder truncateArgs(TruncateArgsConfig config) { + this.truncateArgsConfig = config; + return this; + } + + public CompactionConfig build() { + return new CompactionConfig(this); + } + } + + // ------------------------------------------------------------------------- + // TruncateArgsConfig + // ------------------------------------------------------------------------- + + /** + * Configuration for the lightweight argument-truncation pass that runs before + * summarization. + * + *

    When triggered, large string arguments of {@code ToolUseBlock}s in older messages + * (before the keep window) are clipped to {@link #getMaxArgLength()} characters. + * This is a cheap, non-LLM operation that prevents context ballooning from verbose + * tool invocations (e.g., {@code write_file}, {@code edit_file}). + * + *

    Defaults (when enabled via {@link Builder#truncateArgs(TruncateArgsConfig)}): + *

      + *
    • Trigger at 25 messages or 40 000 tokens
    • + *
    • Keep the 20 most recent messages untouched
    • + *
    • Max argument length: 2 000 characters
    • + *
    + */ + public static class TruncateArgsConfig { + + private final int triggerMessages; + private final int triggerTokens; + private final int keepMessages; + private final int keepTokens; + private final int maxArgLength; + private final String truncationText; + + private TruncateArgsConfig(TruncateArgsBuilder b) { + this.triggerMessages = b.triggerMessages; + this.triggerTokens = b.triggerTokens; + this.keepMessages = b.keepMessages; + this.keepTokens = b.keepTokens; + this.maxArgLength = b.maxArgLength; + this.truncationText = b.truncationText; + } + + public int getTriggerMessages() { + return triggerMessages; + } + + public int getTriggerTokens() { + return triggerTokens; + } + + public int getKeepMessages() { + return keepMessages; + } + + public int getKeepTokens() { + return keepTokens; + } + + /** Maximum character length of any single tool argument value (default 2 000). */ + public int getMaxArgLength() { + return maxArgLength; + } + + /** Suffix appended after the first 20 characters of a truncated argument. */ + public String getTruncationText() { + return truncationText; + } + + public static TruncateArgsBuilder builder() { + return new TruncateArgsBuilder(); + } + + public static class TruncateArgsBuilder { + + private int triggerMessages = 25; + private int triggerTokens = 40_000; + private int keepMessages = 20; + private int keepTokens = 0; + private int maxArgLength = 2_000; + private String truncationText = "...(argument truncated)"; + + public TruncateArgsBuilder triggerMessages(int triggerMessages) { + this.triggerMessages = triggerMessages; + return this; + } + + public TruncateArgsBuilder triggerTokens(int triggerTokens) { + this.triggerTokens = triggerTokens; + return this; + } + + public TruncateArgsBuilder keepMessages(int keepMessages) { + this.keepMessages = keepMessages; + return this; + } + + public TruncateArgsBuilder keepTokens(int keepTokens) { + this.keepTokens = keepTokens; + return this; + } + + public TruncateArgsBuilder maxArgLength(int maxArgLength) { + this.maxArgLength = maxArgLength; + return this; + } + + public TruncateArgsBuilder truncationText(String truncationText) { + this.truncationText = truncationText; + return this; + } + + public TruncateArgsConfig build() { + return new TruncateArgsConfig(this); + } + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/compaction/ConversationCompactor.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/compaction/ConversationCompactor.java new file mode 100644 index 000000000..3aff78bbe --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/compaction/ConversationCompactor.java @@ -0,0 +1,600 @@ +/* + * 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.memory.compaction; + +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import io.agentscope.core.model.Model; +import io.agentscope.harness.agent.hook.CompactionHook; +import io.agentscope.harness.agent.memory.MemoryFlushManager; +import io.agentscope.harness.agent.memory.compaction.CompactionConfig.TruncateArgsConfig; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import reactor.core.publisher.Mono; + +/** + *

    Algorithm

    + *
      + *
    1. Check trigger — token count or message count exceeds threshold
    2. + *
    3. Determine cutoff — find the earliest index that keeps the tail within the + * "keep" budget; never split an ASSISTANT tool-call from its TOOL result(s)
    4. + *
    5. Memory flush (optional) — extract long-term memories from the prefix via + * {@link MemoryFlushManager#flushMemories}
    6. + *
    7. Message offload (optional) — persist the full conversation to the session + * JSONL via {@link MemoryFlushManager#offloadMessages}
    8. + *
    9. Summarize — one LLM call to distill the prefix into a structured summary
    10. + *
    11. Rebuild — return {@code [summaryUserMsg] + preservedTail}
    12. + *
    + * + *

    The caller is responsible for updating both the agent's working memory and the LLM-facing + * message list (see {@link CompactionHook}). + */ +public class ConversationCompactor { + + private static final Logger log = LoggerFactory.getLogger(ConversationCompactor.class); + + /** Marker stored in message name to identify injected summary messages. */ + public static final String SUMMARY_MSG_NAME = "__compaction_summary__"; + + private final Model model; + private final MemoryFlushManager flushManager; + + public ConversationCompactor(Model model, MemoryFlushManager flushManager) { + this.model = model; + this.flushManager = flushManager; + } + + // ------------------------------------------------------------------------- + // Public API + // ------------------------------------------------------------------------- + + /** + * Runs compaction on the supplied conversation messages if a trigger condition is met. + * + *

    Only conversation messages (non-SYSTEM) should be passed. The caller must + * separate system messages before invoking this method and re-prepend them after. + * + * @param conversationMessages non-SYSTEM messages (USER / ASSISTANT / TOOL) + * @param config compaction configuration + * @param agentId agent identifier used for the memory offload path + * @param sessionId session identifier used for the memory offload path + * @return {@code Optional.empty()} when no compaction was needed; otherwise the replacement + * message list consisting of {@code [summaryUserMsg] + preservedTail} + */ + public Mono>> compactIfNeeded( + List conversationMessages, + CompactionConfig config, + String agentId, + String sessionId) { + + if (conversationMessages == null || conversationMessages.isEmpty()) { + return Mono.just(Optional.empty()); + } + + // Step 1: Lightweight arg truncation (non-LLM). Runs at a lower threshold than + List messages = truncateArgs(conversationMessages, config.getTruncateArgsConfig()); + + int totalTokens = TokenCounterUtil.calculateToken(messages); + if (!shouldCompact(messages, totalTokens, config)) { + return Mono.just(Optional.empty()); + } + + int cutoff = determineCutoffIndex(messages, totalTokens, config); + if (cutoff <= 0) { + log.debug("Compaction triggered but safe cutoff is 0 — skipping"); + return Mono.just(Optional.empty()); + } + + // Filter previous summary messages from the prefix before offloading to avoid + // re-storing already-archived summaries. + List prefix = filterSummaryMessages(new ArrayList<>(messages.subList(0, cutoff))); + List tail = new ArrayList<>(messages.subList(cutoff, messages.size())); + + log.info( + "Compaction triggered: total={} msgs / {} tokens, cutoff={}, keeping={} msgs", + messages.size(), + totalTokens, + cutoff, + tail.size()); + + // Step 2: Flush long-term memories from the prefix (best-effort). + Mono flushStep = + config.isFlushBeforeCompact() + ? flushManager + .flushMemories(prefix) + .doOnSuccess(v -> log.debug("Memory flush before compaction done")) + .onErrorResume( + e -> { + log.warn( + "Memory flush before compaction failed: {}", + e.getMessage()); + return Mono.empty(); + }) + : Mono.empty(); + + // Step 3: Offload raw messages to JSONL and capture the file path. + // If offload fails, we continue with null — the summary message falls back to the + // simple format without a file reference. + Mono offloadStep; + if (config.isOffloadBeforeCompact()) { + offloadStep = + Mono.fromCallable( + () -> { + flushManager.offloadMessages(messages, agentId, sessionId); + return flushManager.resolveOffloadPath(agentId, sessionId); + }) + .doOnSuccess( + path -> + log.debug( + "Message offload before compaction done," + + " path={}", + path)) + .onErrorResume( + e -> { + log.warn( + "Message offload before compaction failed: {}", + e.getMessage()); + return Mono.just(""); + }); + } else { + offloadStep = Mono.just(""); + } + + // Step 4: LLM summarization of the prefix, combined with the offload result. + return flushStep + .then(offloadStep) + .flatMap( + offloadPath -> + summarizePrefix(prefix, config) + .map( + summary -> { + String filePath = + offloadPath.isBlank() + ? null + : offloadPath; + Msg summaryMsg = + buildSummaryMessage(summary, filePath); + List compacted = new ArrayList<>(); + compacted.add(summaryMsg); + compacted.addAll(tail); + log.info( + "Compaction complete: {} msgs → 1" + + " summary + {} tail = {}" + + " total", + messages.size(), + tail.size(), + compacted.size()); + return Optional.of(compacted); + })); + } + + // ------------------------------------------------------------------------- + // Trigger logic + // ------------------------------------------------------------------------- + + private static boolean shouldCompact( + List messages, int totalTokens, CompactionConfig config) { + if (config.getTriggerMessages() > 0 && messages.size() >= config.getTriggerMessages()) { + log.debug( + "Compaction trigger: message count {} >= {}", + messages.size(), + config.getTriggerMessages()); + return true; + } + if (config.getTriggerTokens() > 0 && totalTokens >= config.getTriggerTokens()) { + log.debug( + "Compaction trigger: token count {} >= {}", + totalTokens, + config.getTriggerTokens()); + return true; + } + return false; + } + + // ------------------------------------------------------------------------- + // Cutoff / partition logic + // ------------------------------------------------------------------------- + + /** + * Determines the cutoff index separating the prefix-to-summarize from the tail-to-keep. + * + *

    The cutoff is adjusted so that ASSISTANT/TOOL pairs are never split. + */ + private static int determineCutoffIndex( + List messages, int totalTokens, CompactionConfig config) { + int rawCutoff; + if (config.getKeepTokens() > 0) { + rawCutoff = findTokenBasedCutoff(messages, totalTokens, config.getKeepTokens()); + } else { + rawCutoff = findMessageBasedCutoff(messages, config.getKeepMessages()); + } + return findSafeCutoffPoint(messages, rawCutoff); + } + + /** Returns the earliest index such that {@code messages[index:]} fits within the token budget. */ + private static int findTokenBasedCutoff(List messages, int totalTokens, int keepTokens) { + if (totalTokens <= keepTokens) { + return 0; + } + // Binary search for the earliest index where the suffix token count <= keepTokens + int left = 0; + int right = messages.size(); + int candidate = messages.size(); + int maxIter = Integer.SIZE - Integer.numberOfLeadingZeros(messages.size()) + 1; + for (int i = 0; i < maxIter && left < right; i++) { + int mid = (left + right) / 2; + if (TokenCounterUtil.calculateToken(messages.subList(mid, messages.size())) + <= keepTokens) { + candidate = mid; + right = mid; + } else { + left = mid + 1; + } + } + // Clamp so at least 1 message is always kept + return Math.min(candidate, messages.size() - 1); + } + + /** Returns the cutoff that keeps the last {@code keepMessages} messages verbatim. */ + private static int findMessageBasedCutoff(List messages, int keepMessages) { + if (messages.size() <= keepMessages) { + return 0; + } + return messages.size() - keepMessages; + } + + /** + * Adjusts the cutoff to avoid splitting ASSISTANT tool-call/TOOL-result pairs. + * + *

    If the message at {@code cutoffIndex} has role TOOL, we search backward for the + * ASSISTANT message whose tool-use blocks correspond to those tool results and move the + * cutoff to include that ASSISTANT message in the prefix (i.e., cut before it). + * + */ + private static int findSafeCutoffPoint(List messages, int cutoffIndex) { + if (cutoffIndex <= 0 || cutoffIndex >= messages.size()) { + return cutoffIndex; + } + + Msg atCutoff = messages.get(cutoffIndex); + if (atCutoff.getRole() != MsgRole.TOOL) { + return cutoffIndex; + } + + // Collect tool-call IDs from consecutive TOOL messages at/after the cutoff + List toolCallIds = new ArrayList<>(); + int idx = cutoffIndex; + while (idx < messages.size() && messages.get(idx).getRole() == MsgRole.TOOL) { + for (ContentBlock block : messages.get(idx).getContent()) { + if (block instanceof ToolResultBlock tr && tr.getId() != null) { + toolCallIds.add(tr.getId()); + } + } + idx++; + } + + if (toolCallIds.isEmpty()) { + // No IDs found — advance past all TOOL messages to avoid orphaned results + return idx; + } + + // Search backward for the ASSISTANT message that issued those tool calls + for (int i = cutoffIndex - 1; i >= 0; i--) { + Msg msg = messages.get(i); + if (msg.getRole() == MsgRole.ASSISTANT) { + for (ContentBlock block : msg.getContent()) { + if (block instanceof ToolUseBlock tu && toolCallIds.contains(tu.getId())) { + // Move the cutoff to just before this ASSISTANT message + return i; + } + } + } + } + + // Fallback: advance past all TOOL messages + return idx; + } + + // ------------------------------------------------------------------------- + // Summarization + // ------------------------------------------------------------------------- + + private Mono summarizePrefix(List prefix, CompactionConfig config) { + if (prefix.isEmpty()) { + return Mono.just("No previous conversation history."); + } + + String formatted = formatMessagesForSummary(prefix); + String prompt = config.getSummaryPrompt().replace("{messages}", formatted); + + List summarizationInput = + List.of( + Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text(prompt).build()) + .build()); + + return model.stream(summarizationInput, null, null) + .reduce( + new StringBuilder(), + (sb, resp) -> { + if (resp.getContent() != null) { + for (ContentBlock block : resp.getContent()) { + if (block instanceof TextBlock tb && tb.getText() != null) { + sb.append(tb.getText()); + } + } + } + return sb; + }) + .map(StringBuilder::toString) + .map(String::strip) + .filter(s -> !s.isBlank()) + .defaultIfEmpty("(Summary unavailable)") + .onErrorResume( + e -> { + log.warn("Summarization LLM call failed: {}", e.getMessage()); + return Mono.just("(Summarization failed: " + e.getMessage() + ")"); + }); + } + + /** + * Formats a list of messages as a human-readable text block for the summarization LLM. + * + *

    Renders TEXT blocks verbatim; TOOL_USE and TOOL_RESULT blocks as concise inline + * representations so the summarizer understands what actions were taken. + */ + static String formatMessagesForSummary(List messages) { + return messages.stream() + .filter(m -> m.getRole() != null && m.getRole() != MsgRole.SYSTEM) + .map(ConversationCompactor::renderMessageForSummary) + .filter(s -> !s.isBlank()) + .collect(Collectors.joining("\n\n")); + } + + private static String renderMessageForSummary(Msg msg) { + String roleLabel = + switch (msg.getRole()) { + case USER -> "Human"; + case ASSISTANT -> "AI"; + case TOOL -> "Tool"; + default -> msg.getRole().name(); + }; + + StringBuilder sb = new StringBuilder(roleLabel).append(": "); + boolean first = true; + for (ContentBlock block : msg.getContent()) { + if (!first) sb.append(" "); + first = false; + if (block instanceof TextBlock tb && tb.getText() != null && !tb.getText().isBlank()) { + sb.append(tb.getText().strip()); + } else if (block instanceof ToolUseBlock tu) { + sb.append("[tool_call: ").append(tu.getName()).append("]"); + } else if (block instanceof ToolResultBlock tr) { + String text = extractToolResultText(tr); + sb.append("[tool_result: ") + .append(tr.getName() != null ? tr.getName() : "?") + .append("] "); + if (!text.isBlank()) { + sb.append(text.length() > 500 ? text.substring(0, 500) + "..." : text); + } + } + } + return sb.toString().strip(); + } + + private static String extractToolResultText(ToolResultBlock tr) { + if (tr.getOutput() == null) return ""; + return tr.getOutput().stream() + .filter(b -> b instanceof TextBlock) + .map(b -> ((TextBlock) b).getText()) + .filter(t -> t != null && !t.isBlank()) + .collect(Collectors.joining(" ")); + } + + // ------------------------------------------------------------------------- + // Summary message construction + // ------------------------------------------------------------------------- + + /** + * Builds a USER message carrying the summary. + * + *

    When {@code filePath} is non-null, the message includes a reference to where the full + * conversation history was offloaded. + * When null, falls back to the simple "summary to date" format. + * + *

    The message name is set to {@link #SUMMARY_MSG_NAME} so hooks can identify and + * skip summary messages during future flush/offload cycles. + */ + private static Msg buildSummaryMessage(String summary, String filePath) { + String content; + if (filePath != null) { + content = + "You are in the middle of a conversation that has been summarized.\n\n" + + "The full conversation history has been saved to " + + filePath + + " should you need to refer back to it for details.\n\n" + + "A condensed summary follows:\n\n" + + "

    \n" + + summary + + "\n"; + } else { + content = "Here is a summary of the conversation to date:\n\n" + summary; + } + return Msg.builder() + .role(MsgRole.USER) + .name(SUMMARY_MSG_NAME) + .content(TextBlock.builder().text(content).build()) + .build(); + } + + // ------------------------------------------------------------------------- + // Summary message filtering (chained summarization support) + // ------------------------------------------------------------------------- + + /** + * Removes previously injected summary messages from a list. + * + *

    During chained summarization the working memory may already contain a summary USER + * message from a prior compaction round. We filter these out before offloading to the + * backend so the original messages (already stored there) are not duplicated. + */ + static List filterSummaryMessages(List messages) { + return messages.stream() + .filter(m -> !SUMMARY_MSG_NAME.equals(m.getName())) + .collect(Collectors.toList()); + } + + // ------------------------------------------------------------------------- + // Argument truncation (pre-summarization, non-LLM) + // ------------------------------------------------------------------------- + + /** + * Truncates large {@code ToolUseBlock} argument values in old messages. + * + *

    This is a lightweight, non-LLM pass that fires at a separate (lower) threshold + * than full summarization. Only messages before the keep window are modified; recent + * messages are left intact. + * + *

    When {@code truncateConfig} is {@code null}, the original list is returned unchanged. + */ + List truncateArgs(List messages, TruncateArgsConfig truncateConfig) { + if (truncateConfig == null || messages == null || messages.isEmpty()) { + return messages; + } + + int totalTokens = TokenCounterUtil.calculateToken(messages); + if (!shouldTruncateArgs(messages, totalTokens, truncateConfig)) { + return messages; + } + + int cutoff = determineTruncateCutoff(messages, truncateConfig); + if (cutoff >= messages.size()) { + return messages; // Nothing in the truncation window + } + + boolean anyModified = false; + List result = new ArrayList<>(messages.size()); + for (int i = 0; i < messages.size(); i++) { + Msg msg = messages.get(i); + if (i < cutoff && msg.getRole() == MsgRole.ASSISTANT) { + Msg truncated = truncateToolUseArgs(msg, truncateConfig); + result.add(truncated); + if (truncated != msg) { + anyModified = true; + } + } else { + result.add(msg); + } + } + + if (anyModified) { + log.debug("Arg truncation applied to messages before index {}", cutoff); + } + return anyModified ? result : messages; + } + + private static boolean shouldTruncateArgs( + List messages, int totalTokens, TruncateArgsConfig cfg) { + if (cfg.getTriggerMessages() > 0 && messages.size() >= cfg.getTriggerMessages()) { + return true; + } + return cfg.getTriggerTokens() > 0 && totalTokens >= cfg.getTriggerTokens(); + } + + private static int determineTruncateCutoff(List messages, TruncateArgsConfig cfg) { + if (cfg.getKeepTokens() > 0) { + // Token-budget-based keep window: scan from the end + int tokensKept = 0; + for (int i = messages.size() - 1; i >= 0; i--) { + int msgTokens = TokenCounterUtil.calculateToken(List.of(messages.get(i))); + if (tokensKept + msgTokens > cfg.getKeepTokens()) { + return i + 1; + } + tokensKept += msgTokens; + } + return 0; + } + // Message-count keep window + int keep = cfg.getKeepMessages(); + return Math.max(0, messages.size() - keep); + } + + /** + * Returns a copy of the message with large {@code ToolUseBlock} argument values shortened. + * If no argument exceeds the limit, the original message reference is returned unchanged. + */ + private static Msg truncateToolUseArgs(Msg msg, TruncateArgsConfig cfg) { + List blocks = msg.getContent(); + if (blocks == null || blocks.isEmpty()) { + return msg; + } + + boolean anyModified = false; + List newBlocks = new ArrayList<>(blocks.size()); + for (ContentBlock block : blocks) { + if (block instanceof ToolUseBlock tu) { + ToolUseBlock truncated = truncateToolUseBlock(tu, cfg); + newBlocks.add(truncated); + if (truncated != tu) { + anyModified = true; + } + } else { + newBlocks.add(block); + } + } + + if (!anyModified) { + return msg; + } + return Msg.builder().role(msg.getRole()).name(msg.getName()).content(newBlocks).build(); + } + + /** + * Returns a copy of the {@code ToolUseBlock} with large string arg values truncated, + * or the original if no truncation was needed. + */ + private static ToolUseBlock truncateToolUseBlock(ToolUseBlock tu, TruncateArgsConfig cfg) { + Map input = tu.getInput(); + if (input == null || input.isEmpty()) { + return tu; + } + + boolean anyModified = false; + Map newInput = new HashMap<>(input); + for (Map.Entry entry : input.entrySet()) { + if (entry.getValue() instanceof String s && s.length() > cfg.getMaxArgLength()) { + newInput.put(entry.getKey(), s.substring(0, 20) + cfg.getTruncationText()); + anyModified = true; + } + } + + if (!anyModified) { + return tu; + } + return ToolUseBlock.builder().id(tu.getId()).name(tu.getName()).input(newInput).build(); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/compaction/TokenCounterUtil.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/compaction/TokenCounterUtil.java new file mode 100644 index 000000000..f927d03b5 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/compaction/TokenCounterUtil.java @@ -0,0 +1,256 @@ +/* + * 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.memory.compaction; + +import io.agentscope.core.message.ContentBlock; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.message.ToolResultBlock; +import io.agentscope.core.message.ToolUseBlock; +import java.util.List; +import java.util.Map; + +/** + * Utility class for estimating token count in messages. + * + *

    This class provides methods to estimate the number of input tokens that would be + * consumed when sending messages to an LLM. The estimation uses a character-based + * approximation that works reasonably well for both English and Chinese text. + * + *

    Token estimation strategy: + *

      + *
    • Text content: ~1 token per 2-4 characters (varies by language) + *
    • Tool calls: Includes tool name, parameters, and structure overhead + *
    • Tool results: Includes output content and structure overhead + *
    • Message structure: Role, name, and formatting overhead + *
    + */ +public class TokenCounterUtil { + + // Token estimation ratios + // For English: ~1 token per 4 characters + // For Chinese: ~1 token per 1-2 characters + // Using a conservative ratio that works for mixed content + private static final double CHARS_PER_TOKEN = 2.5; + + // Overhead tokens for message structure (role, name, formatting) + private static final int MESSAGE_OVERHEAD = 5; + + // Overhead tokens for tool call structure + private static final int TOOL_CALL_OVERHEAD = 10; + + // Overhead tokens for tool result structure + private static final int TOOL_RESULT_OVERHEAD = 8; + + /** + * Calculates the estimated total input tokens for a list of messages. + * + *

    This method estimates tokens by: + *

      + *
    • Extracting all text content from messages + *
    • Counting characters in tool calls and results + *
    • Adding structure overhead for each message and content block + *
    + * + * @param messages the list of messages to estimate tokens for + * @return estimated number of input tokens + */ + public static int calculateToken(List messages) { + if (messages == null || messages.isEmpty()) { + return 0; + } + + int totalTokens = 0; + + for (Msg msg : messages) { + totalTokens += estimateMessageTokens(msg); + } + + return totalTokens; + } + + /** + * Estimates tokens for a single message. + * + * @param msg the message to estimate + * @return estimated number of tokens for this message + */ + private static int estimateMessageTokens(Msg msg) { + if (msg == null) { + return 0; + } + + int tokens = MESSAGE_OVERHEAD; + + // Add overhead for role and name + if (msg.getRole() != null) { + tokens += estimateTextTokens(msg.getRole().name()); + } + if (msg.getName() != null) { + tokens += estimateTextTokens(msg.getName()); + } + + // Estimate tokens for content blocks + List content = msg.getContent(); + if (content != null) { + for (ContentBlock block : content) { + tokens += estimateContentBlockTokens(block); + } + } + + return tokens; + } + + /** + * Estimates tokens for a content block. + * + * @param block the content block to estimate + * @return estimated number of tokens for this block + */ + private static int estimateContentBlockTokens(ContentBlock block) { + if (block == null) { + return 0; + } + + if (block instanceof TextBlock textBlock) { + return estimateTextTokens(textBlock.getText()); + } else if (block instanceof ToolUseBlock toolUseBlock) { + return estimateToolUseBlockTokens(toolUseBlock); + } else if (block instanceof ToolResultBlock toolResultBlock) { + return estimateToolResultBlockTokens(toolResultBlock); + } + + // For other block types (ImageBlock, AudioBlock, etc.), estimate minimal overhead + return 5; + } + + /** + * Estimates tokens for a ToolUseBlock. + * + * @param toolUseBlock the tool use block to estimate + * @return estimated number of tokens + */ + private static int estimateToolUseBlockTokens(ToolUseBlock toolUseBlock) { + int tokens = TOOL_CALL_OVERHEAD; + + // Tool name + if (toolUseBlock.getName() != null) { + tokens += estimateTextTokens(toolUseBlock.getName()); + } + + // Tool ID + if (toolUseBlock.getId() != null) { + tokens += estimateTextTokens(toolUseBlock.getId()); + } + + // Tool input parameters + Map input = toolUseBlock.getInput(); + if (input != null && !input.isEmpty()) { + // Estimate tokens for JSON representation of parameters + String inputJson = estimateMapAsJson(input); + tokens += estimateTextTokens(inputJson); + } + + // Raw content (if present) + if (toolUseBlock.getContent() != null) { + tokens += estimateTextTokens(toolUseBlock.getContent()); + } + + return tokens; + } + + /** + * Estimates tokens for a ToolResultBlock. + * + * @param toolResultBlock the tool result block to estimate + * @return estimated number of tokens + */ + private static int estimateToolResultBlockTokens(ToolResultBlock toolResultBlock) { + int tokens = TOOL_RESULT_OVERHEAD; + + // Tool name + if (toolResultBlock.getName() != null) { + tokens += estimateTextTokens(toolResultBlock.getName()); + } + + // Tool ID + if (toolResultBlock.getId() != null) { + tokens += estimateTextTokens(toolResultBlock.getId()); + } + + // Output content blocks + List output = toolResultBlock.getOutput(); + if (output != null) { + for (ContentBlock outputBlock : output) { + tokens += estimateContentBlockTokens(outputBlock); + } + } + + return tokens; + } + + /** + * Estimates tokens for text content. + * + *

    Uses a character-based approximation that works reasonably well + * for both English and Chinese text. + * + * @param text the text to estimate + * @return estimated number of tokens + */ + private static int estimateTextTokens(String text) { + if (text == null || text.isEmpty()) { + return 0; + } + + // Count characters and apply ratio + int charCount = text.length(); + return (int) Math.ceil(charCount / CHARS_PER_TOKEN); + } + + /** + * Estimates the JSON string representation of a map for token counting. + * + *

    This is a simplified estimation that counts keys and string values. + * + * @param map the map to estimate + * @return estimated JSON string length + */ + private static String estimateMapAsJson(Map map) { + if (map == null || map.isEmpty()) { + return "{}"; + } + + StringBuilder sb = new StringBuilder(); + sb.append("{"); + boolean first = true; + for (Map.Entry entry : map.entrySet()) { + if (!first) { + sb.append(","); + } + first = false; + sb.append("\"").append(entry.getKey()).append("\":"); + Object value = entry.getValue(); + if (value instanceof String) { + sb.append("\"").append(value).append("\""); + } else { + sb.append(value != null ? value.toString() : "null"); + } + } + sb.append("}"); + return sb.toString(); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/compaction/ToolResultEvictionConfig.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/compaction/ToolResultEvictionConfig.java new file mode 100644 index 000000000..602dc8e8b --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/compaction/ToolResultEvictionConfig.java @@ -0,0 +1,155 @@ +/* + * 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.memory.compaction; + +import java.util.Set; + +/** + * Configuration for per-tool-result eviction of oversized outputs. + * + *

    When a tool produces a result whose text content exceeds {@link #getMaxResultChars()}, the + * full output is written to the workspace filesystem abstraction at a deterministic path under + * {@link #getEvictionPath()}, and the in-context {@link io.agentscope.core.message.ToolResultBlock} + * is replaced with a compact placeholder that includes a head+tail preview and an instruction to + * use {@code readFile} for the full content. + * + *

    This mechanism is orthogonal to conversation summarization ({@link CompactionConfig}): + *

      + *
    • Eviction addresses context width — individual messages that are too large.
    • + *
    • Compaction addresses context depth — too many accumulated messages.
    • + *
    + * Both operate independently on different trigger conditions and different lifecycle events. + * + *
      + *
    • Trigger at 80,000 characters (~20 K tokens at 4 chars/token)
    • + *
    • Preview: first + last 2,000 characters of the original output
    • + *
    • Eviction path prefix: {@code /large_tool_results}
    • + *
    • Excluded tools: filesystem read/write/edit/list + memory tools (small or self-paginating)
    • + *
    + */ +public class ToolResultEvictionConfig { + + /** ~20 K tokens × 4 chars/token — default eviction threshold. */ + public static final int DEFAULT_MAX_RESULT_CHARS = 80_000; + + /** Characters to show at head and tail in the eviction placeholder preview. */ + public static final int DEFAULT_PREVIEW_CHARS = 2_000; + + /** Root path prefix under which evicted results are stored. */ + public static final String DEFAULT_EVICTION_PATH = "/large_tool_results"; + + /** + * Tools excluded from eviction by default. + * + *
      + *
    • {@code read_file} — evicting would cause re-read loops; pagination handles size
    • + *
    • {@code write_file}, {@code edit_file} — return tiny success messages
    • + *
    • {@code grep_files}, {@code glob_files}, {@code list_files} — self-limiting outputs
    • + *
    • {@code memory_search}, {@code memory_get}, {@code session_search} — small/paginated results
    • + *
    + * + * Shell ({@code execute}) is intentionally NOT excluded: command output can be very large. + */ + public static final Set DEFAULT_EXCLUDED_TOOLS = + Set.of( + "read_file", + "write_file", + "edit_file", + "grep_files", + "glob_files", + "list_files", + "memory_search", + "memory_get", + "session_search"); + + private final int maxResultChars; + private final int previewChars; + private final String evictionPath; + private final Set excludedToolNames; + + private ToolResultEvictionConfig(Builder builder) { + this.maxResultChars = builder.maxResultChars; + this.previewChars = builder.previewChars; + this.evictionPath = builder.evictionPath; + this.excludedToolNames = builder.excludedToolNames; + } + + /** Creates a config with all defaults applied. */ + public static ToolResultEvictionConfig defaults() { + return new Builder().build(); + } + + /** Maximum text length (chars) before eviction fires. */ + public int getMaxResultChars() { + return maxResultChars; + } + + /** Characters to show in the head and tail preview. */ + public int getPreviewChars() { + return previewChars; + } + + /** Root path under which evicted files are written (e.g. {@code /large_tool_results}). */ + public String getEvictionPath() { + return evictionPath; + } + + /** Tool names that will never be evicted regardless of result size. */ + public Set getExcludedToolNames() { + return excludedToolNames; + } + + public static Builder builder() { + return new Builder(); + } + + /** Builder for {@link ToolResultEvictionConfig}. */ + public static class Builder { + + private int maxResultChars = DEFAULT_MAX_RESULT_CHARS; + private int previewChars = DEFAULT_PREVIEW_CHARS; + private String evictionPath = DEFAULT_EVICTION_PATH; + private Set excludedToolNames = DEFAULT_EXCLUDED_TOOLS; + + /** Sets the character threshold above which eviction is triggered. */ + public Builder maxResultChars(int maxResultChars) { + this.maxResultChars = maxResultChars; + return this; + } + + /** Sets how many characters to include in the head/tail preview. */ + public Builder previewChars(int previewChars) { + this.previewChars = previewChars; + return this; + } + + /** Sets the root filesystem path prefix for evicted files. */ + public Builder evictionPath(String evictionPath) { + this.evictionPath = evictionPath; + return this; + } + + /** Replaces the default set of excluded tool names. */ + public Builder excludedToolNames(Set excludedToolNames) { + this.excludedToolNames = Set.copyOf(excludedToolNames); + return this; + } + + public ToolResultEvictionConfig build() { + return new ToolResultEvictionConfig(this); + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/session/SessionEntry.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/session/SessionEntry.java new file mode 100644 index 000000000..ce0087909 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/session/SessionEntry.java @@ -0,0 +1,174 @@ +/* + * 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.memory.session; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import java.time.Instant; +import java.util.UUID; + +/** + * Base class for all session tree entries (pi-mono-inspired JSONL session model). + * + *

    Each entry has a unique {@code id} and a {@code parentId} forming a tree structure. + * Entries are appended to the session JSONL file in order; they are never deleted. + * + *

    Entry types: + *

      + *
    • {@link MessageEntry} — wraps a single LLM message (user/assistant/tool/system)
    • + *
    • {@link CompactionEntry} — marks a compaction event (non-destructive)
    • + *
    • {@link SummaryEntry} — holds a compaction summary
    • + *
    + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = SessionEntry.MessageEntry.class, name = "message"), + @JsonSubTypes.Type(value = SessionEntry.CompactionEntry.class, name = "compaction"), + @JsonSubTypes.Type(value = SessionEntry.SummaryEntry.class, name = "summary") +}) +@JsonInclude(JsonInclude.Include.NON_NULL) +public abstract sealed class SessionEntry + permits SessionEntry.MessageEntry, SessionEntry.CompactionEntry, SessionEntry.SummaryEntry { + + private final String id; + private final String parentId; + private final Instant timestamp; + + protected SessionEntry(String id, String parentId, Instant timestamp) { + this.id = id != null ? id : UUID.randomUUID().toString(); + this.parentId = parentId; + this.timestamp = timestamp != null ? timestamp : Instant.now(); + } + + public String getId() { + return id; + } + + public String getParentId() { + return parentId; + } + + public Instant getTimestamp() { + return timestamp; + } + + /** + * A message entry wrapping a single message in the conversation. + */ + public static final class MessageEntry extends SessionEntry { + + private final String role; + private final String content; + private final String toolCallId; + + @JsonCreator + public MessageEntry( + @JsonProperty("id") String id, + @JsonProperty("parentId") String parentId, + @JsonProperty("timestamp") Instant timestamp, + @JsonProperty("role") String role, + @JsonProperty("content") String content, + @JsonProperty("toolCallId") String toolCallId) { + super(id, parentId, timestamp); + this.role = role; + this.content = content; + this.toolCallId = toolCallId; + } + + public MessageEntry(String parentId, String role, String content) { + this(null, parentId, null, role, content, null); + } + + public String getRole() { + return role; + } + + public String getContent() { + return content; + } + + public String getToolCallId() { + return toolCallId; + } + } + + /** + * Non-destructive compaction marker. Records which entry is the first kept entry — + * all entries before {@code firstKeptEntryId} are considered compacted (not visible + * to the LLM) but remain in the file for full history replay. + */ + public static final class CompactionEntry extends SessionEntry { + + private final String firstKeptEntryId; + private final String summaryEntryId; + + @JsonCreator + public CompactionEntry( + @JsonProperty("id") String id, + @JsonProperty("parentId") String parentId, + @JsonProperty("timestamp") Instant timestamp, + @JsonProperty("firstKeptEntryId") String firstKeptEntryId, + @JsonProperty("summaryEntryId") String summaryEntryId) { + super(id, parentId, timestamp); + this.firstKeptEntryId = firstKeptEntryId; + this.summaryEntryId = summaryEntryId; + } + + public String getFirstKeptEntryId() { + return firstKeptEntryId; + } + + public String getSummaryEntryId() { + return summaryEntryId; + } + } + + /** + * Holds the text of a compaction summary (the condensed version of compacted messages). + */ + public static final class SummaryEntry extends SessionEntry { + + private final String summary; + private final String format; + + @JsonCreator + public SummaryEntry( + @JsonProperty("id") String id, + @JsonProperty("parentId") String parentId, + @JsonProperty("timestamp") Instant timestamp, + @JsonProperty("summary") String summary, + @JsonProperty("format") String format) { + super(id, parentId, timestamp); + this.summary = summary; + this.format = format != null ? format : "structured"; + } + + public SummaryEntry(String parentId, String summary) { + this(null, parentId, null, summary, "structured"); + } + + public String getSummary() { + return summary; + } + + public String getFormat() { + return format; + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/session/SessionFreshnessEvaluator.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/session/SessionFreshnessEvaluator.java new file mode 100644 index 000000000..811cc6061 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/session/SessionFreshnessEvaluator.java @@ -0,0 +1,93 @@ +/* + * 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.memory.session; + +import java.time.Duration; +import java.time.Instant; +import java.time.LocalTime; +import java.time.ZoneId; +import java.time.ZonedDateTime; + +/** + * Evaluates whether a session is still "fresh" or should be reset. + * + *

    Inspired by agentscope-claw's SessionFreshnessEvaluator. Supports two reset policies: + *

      + *
    • Daily reset: Session resets after a configured hour each day
    • + *
    • Idle timeout: Session resets after inactivity exceeding a threshold
    • + *
    + */ +public class SessionFreshnessEvaluator { + + private final int dailyResetHour; + private final Duration idleTimeout; + private final ZoneId timezone; + + /** + * Creates a freshness evaluator with default settings: + * daily reset at 4 AM, idle timeout of 2 hours, system timezone. + */ + public SessionFreshnessEvaluator() { + this(4, Duration.ofHours(2), ZoneId.systemDefault()); + } + + public SessionFreshnessEvaluator(int dailyResetHour, Duration idleTimeout, ZoneId timezone) { + this.dailyResetHour = dailyResetHour; + this.idleTimeout = idleTimeout; + this.timezone = timezone; + } + + /** + * Determines if the session should be considered stale and reset. + * + * @param lastActivityAt the timestamp of the last activity in the session + * @return true if the session should be reset + */ + public boolean isStale(Instant lastActivityAt) { + if (lastActivityAt == null) { + return true; + } + + Instant now = Instant.now(); + + if (idleTimeout != null + && Duration.between(lastActivityAt, now).compareTo(idleTimeout) > 0) { + return true; + } + + if (dailyResetHour >= 0) { + ZonedDateTime lastActivity = lastActivityAt.atZone(timezone); + ZonedDateTime nowZoned = now.atZone(timezone); + + LocalTime resetTime = LocalTime.of(dailyResetHour, 0); + ZonedDateTime todayReset = nowZoned.toLocalDate().atTime(resetTime).atZone(timezone); + + if (nowZoned.isAfter(todayReset) && lastActivity.isBefore(todayReset)) { + return true; + } + } + + return false; + } + + public int getDailyResetHour() { + return dailyResetHour; + } + + public Duration getIdleTimeout() { + return idleTimeout; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/session/SessionTree.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/session/SessionTree.java new file mode 100644 index 000000000..b0e5676f9 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/memory/session/SessionTree.java @@ -0,0 +1,572 @@ +/* + * 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.memory.session; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.util.JsonUtils; +import io.agentscope.harness.agent.filesystem.AbstractFilesystem; +import io.agentscope.harness.agent.filesystem.model.ReadResult; +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manages an append-only JSONL session tree (pi-mono-inspired). + * + *

    The session file is a JSONL file where each line is a JSON-serialized {@link SessionEntry}. + * Entries form a tree via {@code id}/{@code parentId} links. A companion {@code .log.jsonl} file + * stores the full history for grep-ability (dual-file pattern from pi-mono mom). + * + *

    File layout

    + *
    + *   agents/{agentId}/sessions/{sessionId}.jsonl      — LLM context (compacted)
    + *   agents/{agentId}/sessions/{sessionId}.log.jsonl   — full history (append-only, never compacted)
    + * 
    + * + *

    Persistence model

    + * The local file is the working copy; the remote {@link AbstractFilesystem} (when configured) is + * the cross-replica mirror. On every {@link #load()}, remote content is fetched and union-merged + * with the local file so that entries written on another machine are visible to the current one. + * On every {@link #flush()}, pending entries are appended to the local files synchronously and + * then mirrored to the remote filesystem asynchronously (fire-and-forget, best-effort). + * + *

    Deferred persistence

    + * Entries are buffered in memory and only flushed to disk on the first call to {@link #flush()} + * (typically after the first assistant message). This avoids partial session files from + * failed/short interactions. + */ +public class SessionTree { + + private static final RuntimeContext DEFAULT_FS_RUNTIME = RuntimeContext.empty(); + + private static final Logger log = LoggerFactory.getLogger(SessionTree.class); + + /** + * Daemon executor used for fire-and-forget remote mirrors so that flush() never blocks callers + * on remote I/O. A single thread is intentional: serialises uploads for the same session. + */ + private static final ExecutorService MIRROR_EXECUTOR = + Executors.newSingleThreadExecutor( + r -> { + Thread t = new Thread(r, "session-tree-mirror"); + t.setDaemon(true); + return t; + }); + + private final Path contextFile; + private final Path logFile; + private final Path workspaceRoot; + private final AbstractFilesystem filesystem; + + private final Map entriesById = new LinkedHashMap<>(); + private final List appendOrder = new ArrayList<>(); + private final List pendingWrites = new ArrayList<>(); + + private String lastCompactionFirstKeptId; + private String lastSummaryEntryId; + private boolean loaded = false; + private boolean flushed = false; + + /** + * Creates a SessionTree backed by the given filesystem for remote mirroring. + * + * @param contextFile path to the {@code .jsonl} context file (LLM-facing, compacted) + * @param workspaceRoot root of the agent workspace; used to derive workspace-relative paths + * @param filesystem {@link AbstractFilesystem} used for remote read/write; may be + * {@code null} to disable remote mirroring (local-only mode) + */ + public SessionTree(Path contextFile, Path workspaceRoot, AbstractFilesystem filesystem) { + this.contextFile = contextFile; + String name = contextFile.getFileName().toString(); + String baseName = name.endsWith(".jsonl") ? name.substring(0, name.length() - 6) : name; + this.logFile = contextFile.resolveSibling(baseName + ".log.jsonl"); + this.workspaceRoot = workspaceRoot; + this.filesystem = filesystem; + } + + /** + * Loads existing entries from the local context file into the in-memory tree. + * + *

    This is a local-only, zero-network operation. If the local file is absent, the + * tree starts empty. To additionally pull and union-merge entries from the remote filesystem + * (e.g., before a write that may follow a cross-machine handoff), call + * {@link #syncFromRemote()} after this method. + * + *

    Safe to call multiple times; only loads once. + */ + public void load() { + if (loaded) { + return; + } + loaded = true; + + // Cold-start restore: if local file is absent but remote has a copy, pull it down once. + if (!Files.isRegularFile(contextFile)) { + restoreFromMirror(contextFile); + } + + List localEntries = readLocalEntries(contextFile); + for (SessionEntry entry : localEntries) { + entriesById.put(entry.getId(), entry); + appendOrder.add(entry); + + if (entry instanceof SessionEntry.CompactionEntry ce) { + lastCompactionFirstKeptId = ce.getFirstKeptEntryId(); + lastSummaryEntryId = ce.getSummaryEntryId(); + } + } + } + + /** + * Pulls the remote context file and union-merges any entries not yet present locally. + * + *

    Remote is treated as the authoritative base: remote entries come first, followed by any + * local-only entries (written but not yet mirrored). If the remote has entries the local file + * does not, the local file is overwritten with the merged content and the new entries are + * appended to the local log file. + * + *

    This is a network operation — call it only when cross-machine consistency is + * required (typically in write paths such as + * {@link io.agentscope.harness.agent.memory.MemoryFlushManager}). Read-only tools should use + * {@link #load()} alone to keep queries fast and local. + * + *

    No-op if no filesystem is configured or the remote read fails (failures are logged as + * warnings). + * + *

    {@link #load()} must be called before this method. + */ + public void syncFromRemote() { + if (filesystem == null || workspaceRoot == null) { + return; + } + + List remoteEntries = pullRemoteEntries(contextFile); + if (remoteEntries.isEmpty()) { + return; + } + + Set localIds = + appendOrder.stream().map(SessionEntry::getId).collect(Collectors.toSet()); + + List remoteNewEntries = + remoteEntries.stream().filter(re -> !localIds.contains(re.getId())).toList(); + if (remoteNewEntries.isEmpty()) { + return; + } + + // Rebuild merged list: remote base + local-only extras at the end. + Set remoteIds = + remoteEntries.stream() + .map(SessionEntry::getId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + List merged = new ArrayList<>(remoteEntries); + for (SessionEntry e : appendOrder) { + if (!remoteIds.contains(e.getId())) { + merged.add(e); + } + } + + overwriteFile(contextFile, merged); + appendToFile(logFile, remoteNewEntries); + + // Update in-memory state with the newly discovered remote entries. + for (SessionEntry entry : remoteNewEntries) { + entriesById.put(entry.getId(), entry); + } + // Re-build appendOrder to match the merged order (remote base first). + appendOrder.clear(); + appendOrder.addAll(merged); + for (SessionEntry entry : remoteNewEntries) { + if (entry instanceof SessionEntry.CompactionEntry ce) { + lastCompactionFirstKeptId = ce.getFirstKeptEntryId(); + lastSummaryEntryId = ce.getSummaryEntryId(); + } + } + + log.info( + "syncFromRemote: merged {} new remote entries into local session file {}", + remoteNewEntries.size(), + contextFile.getFileName()); + } + + /** + * Appends an entry to the in-memory tree. The entry will be written to disk + * on the next {@link #flush()} call. + * + * @return the entry (for chaining) + */ + public SessionEntry append(SessionEntry entry) { + entriesById.put(entry.getId(), entry); + appendOrder.add(entry); + pendingWrites.add(entry); + + if (entry instanceof SessionEntry.CompactionEntry ce) { + lastCompactionFirstKeptId = ce.getFirstKeptEntryId(); + lastSummaryEntryId = ce.getSummaryEntryId(); + } + + return entry; + } + + /** + * Flushes all pending entries to both the local context file and the local log file + * synchronously, then schedules an asynchronous best-effort mirror to the remote filesystem. + * + *

    The remote mirror is fire-and-forget: failures are logged as warnings and do not affect + * the return of this method. The local write is always the primary guarantee. + */ + public void flush() { + if (pendingWrites.isEmpty()) { + return; + } + + flushed = true; + List toWrite = new ArrayList<>(pendingWrites); + pendingWrites.clear(); + + appendToFile(contextFile, toWrite); + appendToFile(logFile, toWrite); + scheduleMirror(); + } + + /** + * Returns whether {@link #flush()} has been called at least once. + */ + public boolean isFlushed() { + return flushed; + } + + /** + * Builds the LLM-visible context from the session tree. + * + *

    Returns entries that the LLM should see: + *

      + *
    • If compaction has occurred, starts with the summary entry, then all entries + * from {@code firstKeptEntryId} onward
    • + *
    • If no compaction, returns all message entries in order
    • + *
    + */ + public List buildContext() { + if (appendOrder.isEmpty()) { + return Collections.emptyList(); + } + + if (lastCompactionFirstKeptId == null) { + return new ArrayList<>(appendOrder); + } + + List context = new ArrayList<>(); + + if (lastSummaryEntryId != null) { + SessionEntry summary = entriesById.get(lastSummaryEntryId); + if (summary != null) { + context.add(summary); + } + } + + boolean found = false; + for (SessionEntry entry : appendOrder) { + if (entry.getId().equals(lastCompactionFirstKeptId)) { + found = true; + } + if (found && entry instanceof SessionEntry.MessageEntry) { + context.add(entry); + } + } + + return context; + } + + /** + * Returns all entries in append order (full history). + */ + public List getAllEntries() { + return Collections.unmodifiableList(appendOrder); + } + + /** + * Returns only message entries in append order. + */ + public List getMessageEntries() { + return appendOrder.stream() + .filter(e -> e instanceof SessionEntry.MessageEntry) + .map(e -> (SessionEntry.MessageEntry) e) + .toList(); + } + + public int size() { + return appendOrder.size(); + } + + public Path getContextFile() { + return contextFile; + } + + public Path getLogFile() { + return logFile; + } + + /** + * Syncs entries from the log file that are not yet in the context file. + * This handles offline messages that were appended to the log while the + * agent was inactive. + * + * @return the number of new entries synced + */ + public int syncFromLog() { + restoreFromMirror(logFile); + if (!Files.isRegularFile(logFile)) { + return 0; + } + + int syncCount = 0; + try (BufferedReader reader = Files.newBufferedReader(logFile, StandardCharsets.UTF_8)) { + String line; + while ((line = reader.readLine()) != null) { + line = line.strip(); + if (line.isEmpty()) { + continue; + } + try { + SessionEntry entry = + JsonUtils.getJsonCodec().fromJson(line, SessionEntry.class); + if (!entriesById.containsKey(entry.getId())) { + entriesById.put(entry.getId(), entry); + appendOrder.add(entry); + pendingWrites.add(entry); + syncCount++; + + if (entry instanceof SessionEntry.CompactionEntry ce) { + lastCompactionFirstKeptId = ce.getFirstKeptEntryId(); + lastSummaryEntryId = ce.getSummaryEntryId(); + } + } + } catch (Exception e) { + log.debug("Skipping malformed log entry during sync: {}", e.getMessage()); + } + } + } catch (IOException e) { + log.warn("Failed to sync from log file {}: {}", logFile, e.getMessage()); + } + + if (syncCount > 0) { + log.info("Synced {} offline entries from log to context", syncCount); + } + return syncCount; + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Schedules an asynchronous, best-effort mirror of both session files to the remote + * filesystem. Uses a daemon single-thread executor to serialise uploads and avoid + * blocking the caller on remote I/O. + */ + private void scheduleMirror() { + if (filesystem == null || workspaceRoot == null) { + return; + } + MIRROR_EXECUTOR.execute( + () -> { + mirrorToFilesystem(contextFile); + mirrorToFilesystem(logFile); + }); + } + + /** + * Fetches the remote copy of {@code file} and parses it as JSONL session entries. + * Returns an empty list if no filesystem is configured or the remote read fails. + */ + private List pullRemoteEntries(Path file) { + if (filesystem == null || workspaceRoot == null) { + return List.of(); + } + String relativePath = toWorkspaceRelative(file); + if (relativePath == null || relativePath.isBlank()) { + return List.of(); + } + ReadResult read = filesystem.read(DEFAULT_FS_RUNTIME, relativePath, 0, 0); + if (!read.isSuccess() || read.fileData() == null || read.fileData().content() == null) { + return List.of(); + } + return parseJsonlEntries(read.fileData().content()); + } + + /** + * Reads and parses the local copy of {@code file} as JSONL session entries. + * Returns an empty list if the file does not exist or cannot be read. + */ + private List readLocalEntries(Path file) { + if (!Files.isRegularFile(file)) { + return List.of(); + } + try (BufferedReader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + StringBuilder sb = new StringBuilder(); + String line; + while ((line = reader.readLine()) != null) { + sb.append(line).append('\n'); + } + return parseJsonlEntries(sb.toString()); + } catch (IOException e) { + log.warn("Failed to read local session file {}: {}", file, e.getMessage()); + return List.of(); + } + } + + /** Parses a JSONL string into a list of {@link SessionEntry} objects, skipping bad lines. */ + private List parseJsonlEntries(String content) { + List result = new ArrayList<>(); + for (String line : content.split("\n", -1)) { + line = line.strip(); + if (line.isEmpty()) { + continue; + } + try { + result.add(JsonUtils.getJsonCodec().fromJson(line, SessionEntry.class)); + } catch (Exception e) { + log.warn("Skipping malformed session entry: {}", e.getMessage()); + } + } + return result; + } + + /** Overwrites {@code file} with the serialised form of {@code entries} (TRUNCATE + WRITE). */ + private void overwriteFile(Path file, List entries) { + try { + if (file.getParent() != null) { + Files.createDirectories(file.getParent()); + } + try (BufferedWriter writer = + Files.newBufferedWriter( + file, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE)) { + for (SessionEntry entry : entries) { + writer.write(JsonUtils.getJsonCodec().toJson(entry)); + writer.newLine(); + } + } + } catch (IOException e) { + log.warn("Failed to overwrite session file {}: {}", file, e.getMessage()); + } + } + + private void appendToFile(Path file, List entries) { + try { + if (file.getParent() != null) { + Files.createDirectories(file.getParent()); + } + try (BufferedWriter writer = + Files.newBufferedWriter( + file, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.APPEND)) { + for (SessionEntry entry : entries) { + String json = JsonUtils.getJsonCodec().toJson(entry); + writer.write(json); + writer.newLine(); + } + } + } catch (IOException e) { + log.warn("Failed to append to session file {}: {}", file, e.getMessage()); + } + } + + /** + * Uploads {@code file} to the remote filesystem (full-file upload). Only called from the + * mirror executor thread; failures are logged as warnings. + */ + private void mirrorToFilesystem(Path file) { + if (filesystem == null || workspaceRoot == null || !Files.isRegularFile(file)) { + return; + } + String relativePath = toWorkspaceRelative(file); + if (relativePath == null || relativePath.isBlank()) { + return; + } + try { + byte[] bytes = Files.readAllBytes(file); + filesystem.uploadFiles(DEFAULT_FS_RUNTIME, List.of(Map.entry(relativePath, bytes))); + } catch (IOException e) { + log.warn("Failed to mirror session file {} to filesystem: {}", file, e.getMessage()); + } + } + + /** + * Restores {@code file} from the remote filesystem mirror when the local file is absent. + * Used by {@link #syncFromLog()} to ensure the log file is available locally before reading. + */ + private void restoreFromMirror(Path file) { + if (filesystem == null || workspaceRoot == null || Files.isRegularFile(file)) { + return; + } + String relativePath = toWorkspaceRelative(file); + if (relativePath == null || relativePath.isBlank()) { + return; + } + ReadResult read = filesystem.read(DEFAULT_FS_RUNTIME, relativePath, 0, 0); + if (!read.isSuccess() || read.fileData() == null || read.fileData().content() == null) { + return; + } + try { + if (file.getParent() != null) { + Files.createDirectories(file.getParent()); + } + Files.writeString( + file, + read.fileData().content(), + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING, + StandardOpenOption.WRITE); + } catch (IOException e) { + log.warn( + "Failed to restore session file {} from filesystem mirror: {}", + file, + e.getMessage()); + } + } + + private String toWorkspaceRelative(Path file) { + Path root = workspaceRoot.toAbsolutePath().normalize(); + Path candidate = file.toAbsolutePath().normalize(); + if (!candidate.startsWith(root)) { + return null; + } + return root.relativize(candidate).toString().replace('\\', '/'); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/AbstractBaseSandbox.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/AbstractBaseSandbox.java new file mode 100644 index 000000000..26f83ac27 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/AbstractBaseSandbox.java @@ -0,0 +1,283 @@ +/* + * 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.sandbox; + +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshot; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.util.Objects; +import java.util.concurrent.atomic.AtomicBoolean; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Abstract base implementation of {@link Sandbox} with the 4-branch workspace start logic. + * + *

    4-Branch Start Logic

    + *
    + * Branch A: workspaceRootReady=true  & workspace dir exists   → apply ephemeral-only entries
    + * Branch B: workspaceRootReady=true  & workspace dir missing  → restore from snapshot + ephemeral entries
    + * Branch C: workspaceRootReady=false & snapshot is restorable → hydrate from snapshot + all entries
    + * Branch D: workspaceRootReady=false & no restorable snapshot → fresh init from full workspace spec
    + * 
    + * + *

    Subclasses implement the backend-specific operations: + *

      + *
    • {@link #doExec(String, int)} — execute a shell command in the workspace
    • + *
    • {@link #doPersistWorkspace()} — create a tar archive of the workspace
    • + *
    • {@link #doHydrateWorkspace(InputStream)} — extract a tar archive into the workspace
    • + *
    • {@link #doSetupWorkspace()} — create the workspace root directory
    • + *
    • {@link #doDestroyWorkspace()} — delete the workspace root directory (on shutdown)
    • + *
    • {@link #getWorkspaceRoot()} — return the workspace root path string
    • + *
    + */ +public abstract class AbstractBaseSandbox implements Sandbox { + + private static final Logger log = LoggerFactory.getLogger(AbstractBaseSandbox.class); + + /** Default timeout in seconds for workspace probing commands. */ + private static final int PROBE_TIMEOUT_SECONDS = 10; + + private final SandboxState state; + private final WorkspaceSpecApplier workspaceSpecApplier; + private final AtomicBoolean running = new AtomicBoolean(false); + + protected AbstractBaseSandbox(SandboxState state) { + this.state = state; + this.workspaceSpecApplier = new WorkspaceSpecApplier(state.getWorkspaceSpec().getRoot()); + } + + /** + * Executes the 4-branch workspace start logic. + * + * @throws Exception if the workspace fails to start + */ + @Override + public void start() throws Exception { + WorkspaceSpec spec = state.getWorkspaceSpec(); + SandboxSnapshot snapshot = state.getSnapshot(); + + try { + if (state.isWorkspaceRootReady()) { + // Workspace was ready at last stop — check if it still exists + boolean stillExists = probeWorkspaceRootForPreservedResume(); + if (stillExists) { + // Branch A: workspace preserved — only apply ephemeral entries + log.debug( + "[sandbox] Branch A: workspace preserved, applying ephemeral entries"); + workspaceSpecApplier.applyWorkspaceSpec(spec, true); + } else { + // Branch B: workspace was lost — restore from snapshot + ephemeral entries + log.debug("[sandbox] Branch B: workspace lost, restoring from snapshot"); + if (snapshot != null && snapshot.isRestorable()) { + doSetupWorkspace(); + try (InputStream archive = snapshot.restore()) { + doHydrateWorkspace(archive); + } + workspaceSpecApplier.applyWorkspaceSpec(spec, true); + } else { + // Degrade to Branch D: no usable snapshot + log.warn("[sandbox] Branch B degraded to D: snapshot not restorable"); + doSetupWorkspace(); + workspaceSpecApplier.applyWorkspaceSpec(spec, false); + } + } + } else { + // Workspace was not ready at last stop + if (snapshot != null && snapshot.isRestorable()) { + // Branch C: restore from snapshot + all spec entries + log.debug("[sandbox] Branch C: restoring from snapshot"); + doSetupWorkspace(); + try (InputStream archive = snapshot.restore()) { + doHydrateWorkspace(archive); + } + workspaceSpecApplier.applyWorkspaceSpec(spec, false); + } else { + // Branch D: fresh initialization from full workspace spec + log.debug("[sandbox] Branch D: fresh workspace initialization"); + doSetupWorkspace(); + workspaceSpecApplier.applyWorkspaceSpec(spec, false); + } + } + applyWorkspaceProjectionIfChanged(spec); + state.setWorkspaceRootReady(true); + running.set(true); + } catch (Exception e) { + state.setWorkspaceRootReady(false); + throw new SandboxException.WorkspaceStartException( + java.nio.file.Path.of(state.getWorkspaceSpec().getRoot()), e); + } + } + + /** + * Persists the workspace snapshot and marks the workspace root as ready. + * + * @throws Exception if snapshot persistence fails + */ + @Override + public void stop() throws Exception { + SandboxSnapshot snapshot = state.getSnapshot(); + if (snapshot != null && snapshot.isPersistenceEnabled()) { + try (InputStream archive = doPersistWorkspace()) { + snapshot.persist(archive); + } + } + state.setWorkspaceRootReady(true); + running.set(false); + } + + /** + * {@inheritDoc} + * + *

    Calls {@link #stop()} then {@link #shutdown()}. + * Stop failures are logged but do not prevent shutdown. + */ + @Override + public void close() throws Exception { + try { + stop(); + } catch (Exception e) { + log.warn("[sandbox] Failed to stop sandbox during close, continuing shutdown", e); + } + shutdown(); + } + + @Override + public boolean isRunning() { + return running.get(); + } + + @Override + public SandboxState getState() { + return state; + } + + /** + * Delegates to {@link #doExec(String, int)} with a fallback timeout. + */ + @Override + public ExecResult exec(String command, Integer timeoutSeconds) throws Exception { + int timeout = timeoutSeconds != null ? timeoutSeconds : getDefaultExecTimeoutSeconds(); + return doExec(command, timeout); + } + + @Override + public InputStream persistWorkspace() throws Exception { + return doPersistWorkspace(); + } + + @Override + public void hydrateWorkspace(InputStream archive) throws Exception { + doHydrateWorkspace(archive); + } + + /** + * Probes whether the workspace root directory still exists, using a backend exec. + * + *

    Uses {@code test -d {workspaceRoot}} with a {@value #PROBE_TIMEOUT_SECONDS}-second + * timeout. Returns {@code true} if the command exits with code 0. + * + * @return true if the workspace root exists + */ + protected boolean probeWorkspaceRootForPreservedResume() { + try { + ExecResult result = doExec("test -d " + getWorkspaceRoot(), PROBE_TIMEOUT_SECONDS); + return result.ok(); + } catch (Exception e) { + log.warn( + "[sandbox] Probe for workspace root failed, assuming lost: {}", e.getMessage()); + return false; + } + } + + /** + * Returns the default command execution timeout in seconds. + * + * @return default timeout (120 seconds) + */ + protected int getDefaultExecTimeoutSeconds() { + return 120; + } + + /** + * Executes a shell command within the workspace. + * + * @param command shell command string + * @param timeoutSeconds maximum execution time + * @return execution result + * @throws Exception if execution fails + */ + protected abstract ExecResult doExec(String command, int timeoutSeconds) throws Exception; + + /** + * Creates a tar archive of the current workspace contents. + * + * @return an {@link InputStream} over the tar stream; caller must close + * @throws Exception if archiving fails + */ + protected abstract InputStream doPersistWorkspace() throws Exception; + + /** + * Extracts a tar archive into the workspace. + * + * @param archive the tar archive stream to extract + * @throws Exception if extraction fails + */ + protected abstract void doHydrateWorkspace(InputStream archive) throws Exception; + + /** + * Creates the workspace root directory. + * + * @throws Exception if directory creation fails + */ + protected abstract void doSetupWorkspace() throws Exception; + + /** + * Destroys the workspace root and any backend resources. + * + * @throws Exception if cleanup fails + */ + protected abstract void doDestroyWorkspace() throws Exception; + + /** + * Returns the absolute path of the workspace root directory. + * + * @return workspace root path string + */ + protected abstract String getWorkspaceRoot(); + + private void applyWorkspaceProjectionIfChanged(WorkspaceSpec spec) throws Exception { + WorkspaceProjectionApplier.ProjectionPayload payload = + WorkspaceProjectionApplier.build(spec); + if (payload == null) { + return; + } + if (Objects.equals(payload.hash(), state.getWorkspaceProjectionHash())) { + log.debug("[sandbox] Workspace projection unchanged, skipping"); + return; + } + if (payload.fileCount() > 0) { + try (InputStream archive = new ByteArrayInputStream(payload.tarBytes())) { + doHydrateWorkspace(archive); + } + } + state.setWorkspaceProjectionHash(payload.hash()); + log.debug( + "[sandbox] Workspace projection applied: files={}, hash={}", + payload.fileCount(), + payload.hash()); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/ExecResult.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/ExecResult.java new file mode 100644 index 000000000..90ff50253 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/ExecResult.java @@ -0,0 +1,49 @@ +/* + * 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.sandbox; + +/** + * Result of a sandbox command execution. + * + * @param exitCode process exit code (0 indicates success) + * @param stdout captured standard output + * @param stderr captured standard error + * @param truncated whether output was truncated due to exceeding the maximum capture size + */ +public record ExecResult(int exitCode, String stdout, String stderr, boolean truncated) { + + /** + * Returns {@code true} if the command exited with code 0. + * + * @return true if exit code is 0 + */ + public boolean ok() { + return exitCode == 0; + } + + /** + * Returns combined stdout and stderr, with stderr prefixed with "[stderr]" if non-empty. + * + * @return combined output string + */ + public String combinedOutput() { + if (stderr == null || stderr.isBlank()) { + return stdout != null ? stdout : ""; + } + String out = stdout != null ? stdout : ""; + return out + (out.isBlank() ? "" : "\n") + "[stderr] " + stderr; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/Sandbox.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/Sandbox.java new file mode 100644 index 000000000..04badc254 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/Sandbox.java @@ -0,0 +1,68 @@ +/* + * 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.sandbox; + +import java.io.InputStream; + +/** + * An active sandbox with a fully isolated workspace. + * + *

    Lifecycle: + *

      + *
    1. Acquire via {@link SandboxClient#create} (new) or {@link SandboxClient#resume} (existing) + *
    2. Call {@link #start()} — initializes or restores the workspace + *
    3. Use {@link #exec} for command execution, {@link #persistWorkspace}/{@link #hydrateWorkspace} + * for archive operations + *
    4. Call {@link #stop()} — persists the snapshot (does NOT destroy resources) + *
    5. Call {@link #shutdown()} — destroys backend resources (tmpdir, container) + *
    6. Or use {@link #close()} which calls stop + shutdown in sequence + *
    + * + *

    The distinction between {@code stop()} and {@code shutdown()} is critical: + *

      + *
    • {@code stop()}: persist snapshot only — safe for both self-managed and user-managed + * sandboxes
    • + *
    • {@code shutdown()}: destroy backend resources — only called on self-managed sandboxes
    • + *
    + */ +public interface Sandbox extends AutoCloseable { + + void start() throws Exception; + + void stop() throws Exception; + + default void shutdown() throws Exception { + // no-op by default + } + + @Override + void close() throws Exception; + + boolean isRunning(); + + /** + * Returns the current serializable state of this sandbox. + * + * @return state (may be modified by lifecycle methods) + */ + SandboxState getState(); + + ExecResult exec(String command, Integer timeoutSeconds) throws Exception; + + InputStream persistWorkspace() throws Exception; + + void hydrateWorkspace(InputStream archive) throws Exception; +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxAcquireResult.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxAcquireResult.java new file mode 100644 index 000000000..46876c1c9 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxAcquireResult.java @@ -0,0 +1,58 @@ +/* + * 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.sandbox; + +/** + * Result of acquiring a {@link Sandbox} from {@link SandboxManager}. + * + *

    Two ownership modes: + *

      + *
    • self-managed ({@code selfManaged=true}): the SDK created the sandbox and is + * responsible for its full lifecycle — {@code stop()} + {@code shutdown()} are both called + * after each agent call.
    • + *
    • user-managed ({@code selfManaged=false}): the caller injected a pre-existing + * sandbox; the SDK only calls {@code stop()} and never calls {@code shutdown()}.
    • + *
    + */ +public final class SandboxAcquireResult { + + private final Sandbox sandbox; + private final boolean selfManaged; + + private SandboxAcquireResult(Sandbox sandbox, boolean selfManaged) { + this.sandbox = sandbox; + this.selfManaged = selfManaged; + } + + /** Creates a self-managed result (SDK owns the full lifecycle). */ + public static SandboxAcquireResult selfManaged(Sandbox sandbox) { + return new SandboxAcquireResult(sandbox, true); + } + + /** Creates a user-managed result (caller owns the lifecycle; SDK only calls stop). */ + public static SandboxAcquireResult userManaged(Sandbox sandbox) { + return new SandboxAcquireResult(sandbox, false); + } + + public Sandbox getSandbox() { + return sandbox; + } + + /** Returns {@code true} if the SDK owns the full sandbox lifecycle. */ + public boolean isSelfManaged() { + return selfManaged; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxAware.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxAware.java new file mode 100644 index 000000000..35869bb5d --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxAware.java @@ -0,0 +1,30 @@ +/* + * 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.sandbox; + +/** + * Marks a filesystem that can have its backing {@link Sandbox} injected at runtime. + * + *

    Implemented by {@link SandboxBackedFilesystem} so {@link + * io.agentscope.harness.agent.hook.SandboxLifecycleHook} can set the active sandbox for each + * call and clear it afterward. + */ +public interface SandboxAware { + + void setSandbox(Sandbox sandbox); + + Sandbox getSandbox(); +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxBackedFilesystem.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxBackedFilesystem.java new file mode 100644 index 000000000..3e19b1669 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxBackedFilesystem.java @@ -0,0 +1,187 @@ +/* + * 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.sandbox; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.filesystem.BaseSandboxFilesystem; +import io.agentscope.harness.agent.filesystem.model.ExecuteResponse; +import io.agentscope.harness.agent.filesystem.model.FileDownloadResponse; +import io.agentscope.harness.agent.filesystem.model.FileUploadResponse; +import io.agentscope.harness.agent.store.NamespaceFactory; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * A {@link BaseSandboxFilesystem} that delegates execution to a live {@link Sandbox}. + * + *

    Stable proxy created at agent build time; a fresh {@link Sandbox} is injected on each call + * via the volatile {@code sandbox} field by {@link + * io.agentscope.harness.agent.hook.SandboxLifecycleHook}. + */ +public class SandboxBackedFilesystem extends BaseSandboxFilesystem implements SandboxAware { + + private static final Logger log = LoggerFactory.getLogger(SandboxBackedFilesystem.class); + + private final String fsId; + private volatile Sandbox sandbox; + + public SandboxBackedFilesystem() { + this.fsId = "sandbox-" + UUID.randomUUID().toString().substring(0, 8); + } + + public void configureNamespace(NamespaceFactory factory) { + setNamespaceFactory(factory); + } + + @Override + public void setSandbox(Sandbox sandbox) { + this.sandbox = sandbox; + } + + @Override + public Sandbox getSandbox() { + return sandbox; + } + + @Override + public String id() { + return fsId; + } + + @Override + public ExecuteResponse execute(String command, Integer timeoutSeconds) { + Sandbox active = requireSandbox(); + try { + ExecResult result = active.exec(command, timeoutSeconds); + return new ExecuteResponse( + result.combinedOutput(), result.exitCode(), result.truncated()); + } catch (SandboxException.ExecTimeoutException e) { + return new ExecuteResponse(e.getMessage(), 124, false); + } catch (SandboxException.ExecException e) { + String combined = + (e.getStdout() != null ? e.getStdout() : "") + + (e.getStderr() != null && !e.getStderr().isBlank() + ? "\n" + e.getStderr() + : ""); + return new ExecuteResponse(combined, e.getExitCode(), false); + } catch (Exception e) { + log.error("[sandbox-fs] execute failed: {}", command, e); + return new ExecuteResponse("Internal sandbox error: " + e.getMessage(), -1, false); + } + } + + @Override + public List uploadFiles( + RuntimeContext runtimeContext, List> files) { + Sandbox active = requireSandbox(); + List results = new ArrayList<>(files.size()); + + for (Map.Entry file : files) { + String path = file.getKey(); + byte[] content = file.getValue(); + + try { + String base64Content = Base64.getEncoder().encodeToString(content); + String escapedPath = shellSingleQuote(path); + String cmd = + "mkdir -p $(dirname " + + escapedPath + + ") && " + + "printf '%s' '" + + base64Content + + "' | base64 -d > " + + escapedPath; + + ExecResult result = active.exec(cmd, null); + if (result.ok()) { + results.add(FileUploadResponse.success(path)); + } else { + results.add(FileUploadResponse.fail(path, result.combinedOutput())); + } + } catch (SandboxException.ExecException e) { + String combined = + (e.getStdout() != null ? e.getStdout() : "") + + (e.getStderr() != null && !e.getStderr().isBlank() + ? "\n" + e.getStderr() + : ""); + results.add(FileUploadResponse.fail(path, combined)); + } catch (Exception e) { + log.warn("[sandbox-fs] uploadFiles failed for path: {}", path, e); + results.add(FileUploadResponse.fail(path, e.getMessage())); + } + } + + return results; + } + + @Override + public List downloadFiles( + RuntimeContext runtimeContext, List paths) { + Sandbox active = requireSandbox(); + List results = new ArrayList<>(paths.size()); + + for (String path : paths) { + try { + String escapedPath = shellSingleQuote(path); + String cmd = "base64 " + escapedPath; + + ExecResult result = active.exec(cmd, null); + if (result.ok()) { + byte[] decoded = + Base64.getDecoder() + .decode( + result.stdout() + .trim() + .getBytes(StandardCharsets.UTF_8)); + results.add(FileDownloadResponse.success(path, decoded)); + } else { + results.add(FileDownloadResponse.fail(path, result.combinedOutput())); + } + } catch (SandboxException.ExecException e) { + String combined = + (e.getStdout() != null ? e.getStdout() : "") + + (e.getStderr() != null && !e.getStderr().isBlank() + ? "\n" + e.getStderr() + : ""); + results.add(FileDownloadResponse.fail(path, combined)); + } catch (Exception e) { + log.warn("[sandbox-fs] downloadFiles failed for path: {}", path, e); + results.add(FileDownloadResponse.fail(path, e.getMessage())); + } + } + + return results; + } + + private Sandbox requireSandbox() { + Sandbox s = sandbox; + if (s == null) { + throw new SandboxException.SandboxConfigurationException( + "No active sandbox — sandbox filesystem used outside of a call context"); + } + return s; + } + + private String shellSingleQuote(String s) { + return "'" + s.replace("'", "'\\''") + "'"; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxClient.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxClient.java new file mode 100644 index 000000000..d4eb772a0 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxClient.java @@ -0,0 +1,44 @@ +/* + * 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.sandbox; + +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; + +/** + * Factory for creating and resuming {@link Sandbox} instances. + * + * @param the type of client options for this implementation + */ +public interface SandboxClient { + + /** + * Creates a new sandbox with the given workspace spec and snapshot spec. + * + *

    Returned in a pre-start state; call {@link Sandbox#start()} before use. + */ + Sandbox create(WorkspaceSpec workspaceSpec, SandboxSnapshotSpec snapshotSpec, O options); + + /** + * Resumes a sandbox from previously serialized {@link SandboxState}. + */ + Sandbox resume(SandboxState state); + + void delete(Sandbox sandbox); + + String serializeState(SandboxState state); + + SandboxState deserializeState(String json); +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxClientOptions.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxClientOptions.java new file mode 100644 index 000000000..851ce3559 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxClientOptions.java @@ -0,0 +1,52 @@ +/* + * 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.sandbox; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxClientOptions; + +/** + * Base class for sandbox client configuration options. + * + *

    Each concrete subclass describes a specific sandbox backend (e.g. Docker) and can + * self-instantiate the corresponding {@link SandboxClient} via {@link #createClient()}. + * This allows callers to configure only the options object and rely on + * {@link io.agentscope.harness.agent.HarnessAgent.Builder} to derive the client automatically. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = DockerSandboxClientOptions.class, name = "docker"), +}) +public abstract class SandboxClientOptions { + + /** + * Returns the type discriminator used in JSON serialization. + * + * @return type string (e.g. "docker") + */ + public abstract String getType(); + + /** + * Creates the {@link SandboxClient} implementation that corresponds to these options. + * + *

    Called by {@link io.agentscope.harness.agent.HarnessAgent.Builder} when no explicit + * client has been provided, so callers only need to configure the options object. + * + * @return a new client instance ready for use + */ + public abstract SandboxClient createClient(); +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxContext.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxContext.java new file mode 100644 index 000000000..f1ce071e8 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxContext.java @@ -0,0 +1,130 @@ +/* + * 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.sandbox; + +import io.agentscope.harness.agent.IsolationScope; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; + +/** + * Immutable configuration for sandbox behavior. + * + *

    Built with the agent and attached to {@link io.agentscope.core.agent.RuntimeContext} for + * each call. + */ +public final class SandboxContext { + + private final SandboxClient client; + private final SandboxClientOptions clientOptions; + private final WorkspaceSpec workspaceSpec; + private final SandboxSnapshotSpec snapshotSpec; + private final Sandbox externalSandbox; + private final SandboxState externalSandboxState; + private final IsolationScope isolationScope; + + private SandboxContext(Builder builder) { + this.client = builder.client; + this.clientOptions = builder.clientOptions; + this.workspaceSpec = builder.workspaceSpec; + this.snapshotSpec = builder.snapshotSpec; + this.externalSandbox = builder.externalSandbox; + this.externalSandboxState = builder.externalSandboxState; + this.isolationScope = builder.isolationScope; + } + + public SandboxClient getClient() { + return client; + } + + public SandboxClientOptions getClientOptions() { + return clientOptions; + } + + public WorkspaceSpec getWorkspaceSpec() { + return workspaceSpec; + } + + public SandboxSnapshotSpec getSnapshotSpec() { + return snapshotSpec; + } + + public Sandbox getExternalSandbox() { + return externalSandbox; + } + + public SandboxState getExternalSandboxState() { + return externalSandboxState; + } + + public IsolationScope getIsolationScope() { + return isolationScope; + } + + public static Builder builder() { + return new Builder(); + } + + public static final class Builder { + + private SandboxClient client; + private SandboxClientOptions clientOptions; + private WorkspaceSpec workspaceSpec; + private SandboxSnapshotSpec snapshotSpec; + private Sandbox externalSandbox; + private SandboxState externalSandboxState; + private IsolationScope isolationScope; + + private Builder() {} + + public Builder client(SandboxClient client) { + this.client = client; + return this; + } + + public Builder clientOptions(SandboxClientOptions clientOptions) { + this.clientOptions = clientOptions; + return this; + } + + public Builder workspaceSpec(WorkspaceSpec workspaceSpec) { + this.workspaceSpec = workspaceSpec; + return this; + } + + public Builder snapshotSpec(SandboxSnapshotSpec snapshotSpec) { + this.snapshotSpec = snapshotSpec; + return this; + } + + public Builder externalSandbox(Sandbox externalSandbox) { + this.externalSandbox = externalSandbox; + return this; + } + + public Builder externalSandboxState(SandboxState externalSandboxState) { + this.externalSandboxState = externalSandboxState; + return this; + } + + public Builder isolationScope(IsolationScope isolationScope) { + this.isolationScope = isolationScope; + return this; + } + + public SandboxContext build() { + return new SandboxContext(this); + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxDistributedOptions.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxDistributedOptions.java new file mode 100644 index 000000000..b67db4fbb --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxDistributedOptions.java @@ -0,0 +1,138 @@ +/* + * 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.sandbox; + +import io.agentscope.core.session.Session; +import io.agentscope.harness.agent.sandbox.snapshot.OssSnapshotSpec; +import io.agentscope.harness.agent.sandbox.snapshot.RedisSnapshotSpec; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; + +/** + * High-level distributed sandbox configuration used by + * {@link io.agentscope.harness.agent.HarnessAgent.Builder#sandboxDistributed}. + * + *

    Bundles the pieces required for distributed sandbox restore/sharing that are not already on + * {@link io.agentscope.harness.agent.sandbox.filesystem.SandboxFilesystemSpec}: + * + *

      + *
    • distributed {@link Session} (for state-store slots) + *
    • optional {@link SandboxSnapshotSpec} override (workspace archive persistence) + *
    • {@code requireDistributed} — fail-fast when distributed prerequisites are not met + *
    + * + *

    Configure {@link io.agentscope.harness.agent.IsolationScope} on {@code SandboxFilesystemSpec} + * only; it is not duplicated here. + */ +public final class SandboxDistributedOptions { + + private final Session session; + private final SandboxSnapshotSpec snapshotSpec; + private final boolean requireDistributed; + + private SandboxDistributedOptions(Builder builder) { + this.session = builder.session; + this.snapshotSpec = builder.snapshotSpec; + this.requireDistributed = builder.requireDistributed; + } + + /** + * Creates a builder with safe distributed defaults. + * + *

    Defaults: + * + *

      + *
    • {@code requireDistributed = true} + *
    + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Creates options with OSS snapshot backend and distributed-safe defaults. + */ + public static SandboxDistributedOptions oss(Session session, OssSnapshotSpec snapshotSpec) { + return builder().session(session).snapshotSpec(snapshotSpec).build(); + } + + /** + * Creates options with Redis snapshot backend and distributed-safe defaults. + */ + public static SandboxDistributedOptions redis(Session session, RedisSnapshotSpec snapshotSpec) { + return builder().session(session).snapshotSpec(snapshotSpec).build(); + } + + /** + * Returns the distributed session backend used by {@link SessionSandboxStateStore}. + */ + public Session getSession() { + return session; + } + + /** + * Returns the snapshot spec used for workspace archive persistence. + */ + public SandboxSnapshotSpec getSnapshotSpec() { + return snapshotSpec; + } + + /** + * Whether builder should fail-fast when distributed prerequisites are not met. + */ + public boolean isRequireDistributed() { + return requireDistributed; + } + + public static final class Builder { + + private Session session; + private SandboxSnapshotSpec snapshotSpec; + private boolean requireDistributed = true; + + private Builder() {} + + /** + * Sets distributed session backend (for state slot persistence). + */ + public Builder session(Session session) { + this.session = session; + return this; + } + + /** + * Sets snapshot strategy used for workspace persistence. + */ + public Builder snapshotSpec(SandboxSnapshotSpec snapshotSpec) { + this.snapshotSpec = snapshotSpec; + return this; + } + + /** + * Enables/disables fail-fast checks for distributed prerequisites. + * + *

    When {@code true} (default), builder throws if effective session remains local + * ({@code WorkspaceSession}) or snapshot spec is absent/no-op. + */ + public Builder requireDistributed(boolean requireDistributed) { + this.requireDistributed = requireDistributed; + return this; + } + + public SandboxDistributedOptions build() { + return new SandboxDistributedOptions(this); + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxErrorCode.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxErrorCode.java new file mode 100644 index 000000000..0edec7d63 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxErrorCode.java @@ -0,0 +1,55 @@ +/* + * 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.sandbox; + +/** + * Error codes for sandbox operations, used in {@link SandboxException} and its subclasses. + */ +public enum SandboxErrorCode { + + /** Command exited with a non-zero exit code. */ + EXEC_NONZERO, + + /** Command execution timed out. */ + EXEC_TIMEOUT, + + /** Failed to start the workspace backend. */ + WORKSPACE_START_ERROR, + + /** Failed to stop/persist the workspace backend. */ + WORKSPACE_STOP_ERROR, + + /** Failed to read or parse a workspace archive (tar). */ + WORKSPACE_ARCHIVE_READ_ERROR, + + /** Failed to create a workspace archive (tar). */ + WORKSPACE_ARCHIVE_WRITE_ERROR, + + /** Failed to persist a snapshot. */ + SNAPSHOT_PERSIST_ERROR, + + /** Failed to restore a snapshot. */ + SNAPSHOT_RESTORE_ERROR, + + /** Snapshot does not exist or is not restorable. */ + SNAPSHOT_NOT_RESTORABLE, + + /** A manifest entry contains an invalid or unsafe path. */ + INVALID_MANIFEST_PATH, + + /** Invalid or missing sandbox configuration. */ + CONFIGURATION_ERROR +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxException.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxException.java new file mode 100644 index 000000000..9dbdade2c --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxException.java @@ -0,0 +1,329 @@ +/* + * 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.sandbox; + +import java.nio.file.Path; + +/** + * Base exception for all sandbox operations. + * + *

    Specialized subclasses are provided as static nested classes for common failure modes: + * {@link SandboxConfigurationException}, {@link SandboxRuntimeException}, + * {@link WorkspaceStartException}, {@link WorkspaceStopException}, + * {@link ExecException}, {@link ExecTimeoutException}, {@link SnapshotException}. + */ +public class SandboxException extends RuntimeException { + + private final SandboxErrorCode errorCode; + private final String op; + + /** + * Creates a sandbox exception with an error code and message. + * + * @param errorCode the error code classifying the failure + * @param message human-readable description + */ + public SandboxException(SandboxErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + this.op = null; + } + + /** + * Creates a sandbox exception with an error code, message, and cause. + * + * @param errorCode the error code classifying the failure + * @param message human-readable description + * @param cause the underlying cause + */ + public SandboxException(SandboxErrorCode errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + this.op = null; + } + + /** + * Creates a sandbox exception with an error code, operation name, message, and cause. + * + * @param errorCode the error code classifying the failure + * @param op the operation that failed (e.g. "start", "exec", "persist") + * @param message human-readable description + * @param cause the underlying cause + */ + public SandboxException( + SandboxErrorCode errorCode, String op, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + this.op = op; + } + + /** + * Returns the error code classifying this failure. + * + * @return the error code + */ + public SandboxErrorCode getErrorCode() { + return errorCode; + } + + /** + * Returns the operation name that failed, or {@code null} if not specified. + * + * @return operation name, may be null + */ + public String getOp() { + return op; + } + + // ---- Specialized subclasses ---- + + /** + * Thrown when sandbox configuration is invalid or missing (e.g. no client configured). + */ + public static class SandboxConfigurationException extends SandboxException { + + /** + * Creates a configuration exception. + * + * @param message description of the configuration problem + */ + public SandboxConfigurationException(String message) { + super(SandboxErrorCode.CONFIGURATION_ERROR, message); + } + + /** + * Creates a configuration exception with cause. + * + * @param message description of the configuration problem + * @param cause the underlying cause + */ + public SandboxConfigurationException(String message, Throwable cause) { + super(SandboxErrorCode.CONFIGURATION_ERROR, message, cause); + } + } + + /** + * Thrown for general sandbox runtime failures not covered by a more specific subclass. + */ + public static class SandboxRuntimeException extends SandboxException { + + /** + * Creates a runtime exception with an error code and message. + * + * @param errorCode the error code classifying the failure + * @param message human-readable description + */ + public SandboxRuntimeException(SandboxErrorCode errorCode, String message) { + super(errorCode, message); + } + + /** + * Creates a runtime exception with an error code, message, and cause. + * + * @param errorCode the error code classifying the failure + * @param message human-readable description + * @param cause the underlying cause + */ + public SandboxRuntimeException( + SandboxErrorCode errorCode, String message, Throwable cause) { + super(errorCode, message, cause); + } + + /** + * Creates a runtime exception with a message and cause. + * + * @param message human-readable description + * @param cause the underlying cause + */ + public SandboxRuntimeException(String message, Throwable cause) { + super(SandboxErrorCode.WORKSPACE_START_ERROR, message, cause); + } + } + + /** + * Thrown when the workspace backend fails to start. + */ + public static class WorkspaceStartException extends SandboxException { + + private final Path path; + + /** + * Creates a workspace-start exception. + * + * @param path the workspace path that could not be started + * @param cause the underlying cause + */ + public WorkspaceStartException(Path path, Throwable cause) { + super( + SandboxErrorCode.WORKSPACE_START_ERROR, + "Failed to start workspace at: " + path, + cause); + this.path = path; + } + + /** + * Returns the workspace path that failed to start. + * + * @return workspace path + */ + public Path getPath() { + return path; + } + } + + /** + * Thrown when the workspace backend fails to stop. + */ + public static class WorkspaceStopException extends SandboxException { + + private final Path path; + + /** + * Creates a workspace-stop exception. + * + * @param path the workspace path that could not be stopped + * @param cause the underlying cause + */ + public WorkspaceStopException(Path path, Throwable cause) { + super( + SandboxErrorCode.WORKSPACE_STOP_ERROR, + "Failed to stop workspace at: " + path, + cause); + this.path = path; + } + + /** + * Returns the workspace path that failed to stop. + * + * @return workspace path + */ + public Path getPath() { + return path; + } + } + + /** + * Thrown when a sandbox command exits with a non-zero exit code. + */ + public static class ExecException extends SandboxException { + + private final int exitCode; + private final String stdout; + private final String stderr; + + /** + * Creates an exec exception. + * + * @param exitCode the process exit code + * @param stdout captured standard output + * @param stderr captured standard error + */ + public ExecException(int exitCode, String stdout, String stderr) { + super( + SandboxErrorCode.EXEC_NONZERO, + "Command exited with code " + exitCode + ": " + stderr); + this.exitCode = exitCode; + this.stdout = stdout; + this.stderr = stderr; + } + + /** + * Returns the process exit code. + * + * @return exit code + */ + public int getExitCode() { + return exitCode; + } + + /** + * Returns captured standard output. + * + * @return stdout + */ + public String getStdout() { + return stdout; + } + + /** + * Returns captured standard error. + * + * @return stderr + */ + public String getStderr() { + return stderr; + } + } + + /** + * Thrown when a sandbox command times out. + */ + public static class ExecTimeoutException extends SandboxException { + + /** + * Creates an exec timeout exception. + * + * @param command the command that timed out + * @param timeoutSeconds the timeout that was exceeded + */ + public ExecTimeoutException(String command, int timeoutSeconds) { + super( + SandboxErrorCode.EXEC_TIMEOUT, + "Command timed out after " + timeoutSeconds + "s: " + command); + } + } + + /** + * Thrown when a snapshot operation (persist or restore) fails. + */ + public static class SnapshotException extends SandboxException { + + private final String snapshotId; + + /** + * Creates a snapshot exception. + * + * @param snapshotId the snapshot identifier + * @param message human-readable description + * @param cause the underlying cause + */ + public SnapshotException(String snapshotId, String message, Throwable cause) { + super(SandboxErrorCode.SNAPSHOT_PERSIST_ERROR, message, cause); + this.snapshotId = snapshotId; + } + + /** + * Creates a snapshot exception for a non-restorable snapshot. + * + * @param snapshotId the snapshot identifier + */ + public SnapshotException(String snapshotId) { + super( + SandboxErrorCode.SNAPSHOT_NOT_RESTORABLE, + "Snapshot is not restorable: " + snapshotId); + this.snapshotId = snapshotId; + } + + /** + * Returns the snapshot identifier associated with this failure. + * + * @return snapshot id + */ + public String getSnapshotId() { + return snapshotId; + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxIsolationKey.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxIsolationKey.java new file mode 100644 index 000000000..5aa557cb7 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxIsolationKey.java @@ -0,0 +1,131 @@ +/* + * 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.sandbox; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.IsolationScope; +import java.util.Objects; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Immutable key that uniquely identifies a sandbox state slot for a given + * {@link IsolationScope}. + * + *

    Use {@link #resolve} to obtain a key from a {@link RuntimeContext}. The result is + * {@link Optional#empty()} when the required context field is absent (e.g., {@code USER} scope + * without a {@code userId}), meaning state lookup should be skipped. + */ +public final class SandboxIsolationKey { + + private static final Logger log = LoggerFactory.getLogger(SandboxIsolationKey.class); + + static final String GLOBAL_VALUE = "__global__"; + + private final IsolationScope scope; + private final String value; + + private SandboxIsolationKey(IsolationScope scope, String value) { + this.scope = scope; + this.value = value; + } + + /** + * Resolves an isolation key from the given scope, runtime context, and agent ID. + * + *

    Resolution rules: + *

      + *
    • {@code SESSION} – requires a non-null {@code sessionKey}; value = + * {@code sessionKey.toIdentifier()}. Returns empty if absent.
    • + *
    • {@code USER} – requires a non-blank {@code userId}; value = {@code userId}. + * Logs a warning and returns empty if absent.
    • + *
    • {@code AGENT} – value = {@code agentId} (always present).
    • + *
    • {@code GLOBAL} – value = {@value #GLOBAL_VALUE} (always present).
    • + *
    • {@code null} scope – treated as {@code SESSION}.
    • + *
    + * + * @param scope the desired isolation scope; {@code null} defaults to {@code SESSION} + * @param ctx the runtime context for the current call; may be {@code null} + * @param agentId the agent name resolved at build time; must not be null + * @return resolved key, or empty if the required context field is absent + */ + public static Optional resolve( + IsolationScope scope, RuntimeContext ctx, String agentId) { + IsolationScope effective = scope != null ? scope : IsolationScope.SESSION; + return switch (effective) { + case SESSION -> { + if (ctx == null || ctx.getSessionKey() == null) { + yield Optional.empty(); + } + yield Optional.of( + new SandboxIsolationKey( + IsolationScope.SESSION, ctx.getSessionKey().toIdentifier())); + } + case USER -> { + if (ctx == null || ctx.getUserId() == null || ctx.getUserId().isBlank()) { + log.warn( + "[sandbox] USER isolation scope requested but userId is absent" + + " — skipping state lookup; a fresh sandbox will be" + + " created"); + yield Optional.empty(); + } + yield Optional.of(new SandboxIsolationKey(IsolationScope.USER, ctx.getUserId())); + } + case AGENT -> + Optional.of( + new SandboxIsolationKey( + IsolationScope.AGENT, Objects.requireNonNull(agentId))); + case GLOBAL -> + Optional.of(new SandboxIsolationKey(IsolationScope.GLOBAL, GLOBAL_VALUE)); + }; + } + + /** + * Returns the isolation scope. + * + * @return scope + */ + public IsolationScope getScope() { + return scope; + } + + /** + * Returns the discriminating value within the scope (e.g. session id, user id, agent name). + * + * @return value string + */ + public String getValue() { + return value; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (!(o instanceof SandboxIsolationKey that)) return false; + return scope == that.scope && Objects.equals(value, that.value); + } + + @Override + public int hashCode() { + return Objects.hash(scope, value); + } + + @Override + public String toString() { + return "SandboxIsolationKey{scope=" + scope + ", value='" + value + "'}"; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxManager.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxManager.java new file mode 100644 index 000000000..87e2f5cdf --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxManager.java @@ -0,0 +1,176 @@ +/* + * 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.sandbox; + +import io.agentscope.core.agent.RuntimeContext; +import java.util.Objects; +import java.util.Optional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Manages the lifecycle of {@link Sandbox} instances for the current call. + * + *

    Acquire priority: {@link SandboxContext#getExternalSandbox()} > {@link + * SandboxContext#getExternalSandboxState()} > persisted {@link SandboxState} > {@link + * SandboxClient#create}. + */ +public class SandboxManager { + + private static final Logger log = LoggerFactory.getLogger(SandboxManager.class); + + private final SandboxClient client; + private final SandboxStateStore stateStore; + private final String agentId; + + public SandboxManager(SandboxClient client, SandboxStateStore stateStore, String agentId) { + this.client = Objects.requireNonNull(client, "client must not be null"); + this.stateStore = Objects.requireNonNull(stateStore, "stateStore must not be null"); + this.agentId = Objects.requireNonNull(agentId, "agentId must not be null"); + } + + public SandboxAcquireResult acquire( + SandboxContext sandboxContext, RuntimeContext runtimeContext) throws Exception { + if (sandboxContext.getExternalSandbox() != null) { + Sandbox external = sandboxContext.getExternalSandbox(); + log.debug( + "[sandbox] Priority 1: using user-managed sandbox: {}", + external.getState() != null ? external.getState().getSessionId() : "?"); + return SandboxAcquireResult.userManaged(external); + } + + if (sandboxContext.getExternalSandboxState() != null) { + Sandbox sandbox = client.resume(sandboxContext.getExternalSandboxState()); + log.debug( + "[sandbox] Priority 2: resuming from explicit state: {}", + sandboxContext.getExternalSandboxState().getSessionId()); + return SandboxAcquireResult.selfManaged(sandbox); + } + + Optional scopeKey = + SandboxIsolationKey.resolve( + sandboxContext.getIsolationScope(), runtimeContext, agentId); + if (scopeKey.isPresent()) { + try { + Optional stateJson = stateStore.load(scopeKey.get()); + if (stateJson.isPresent()) { + log.debug( + "[sandbox] Priority 3: resuming from persisted state (scope={})", + scopeKey.get()); + SandboxState state = client.deserializeState(stateJson.get()); + Sandbox sandbox = client.resume(state); + return SandboxAcquireResult.selfManaged(sandbox); + } + } catch (Exception e) { + log.warn( + "[sandbox] Failed to load persisted state for scope {}, falling through" + + " to fresh create: {}", + scopeKey.get(), + e.getMessage(), + e); + } + } + + log.debug("[sandbox] Priority 4: creating new sandbox"); + WorkspaceSpec spec = + sandboxContext.getWorkspaceSpec() != null + ? sandboxContext.getWorkspaceSpec().copy() + : new WorkspaceSpec(); + + @SuppressWarnings("unchecked") + SandboxClient typedClient = + (SandboxClient) client; + Sandbox sandbox = + typedClient.create( + spec, sandboxContext.getSnapshotSpec(), sandboxContext.getClientOptions()); + return SandboxAcquireResult.selfManaged(sandbox); + } + + public void release(SandboxAcquireResult result) { + if (result == null) { + return; + } + Sandbox sandbox = result.getSandbox(); + if (sandbox == null) { + return; + } + + try { + sandbox.stop(); + } catch (Exception e) { + log.warn("[sandbox] Sandbox stop failed: {}", e.getMessage(), e); + } + + if (result.isSelfManaged()) { + try { + sandbox.shutdown(); + } catch (Exception e) { + log.warn("[sandbox] Sandbox shutdown failed: {}", e.getMessage(), e); + } + } + } + + public void persistState( + SandboxAcquireResult result, + SandboxContext sandboxContext, + RuntimeContext runtimeContext) { + if (result == null || result.getSandbox() == null) { + return; + } + SandboxState state = result.getSandbox().getState(); + if (state == null) { + return; + } + + Optional scopeKey = + SandboxIsolationKey.resolve( + sandboxContext != null ? sandboxContext.getIsolationScope() : null, + runtimeContext, + agentId); + if (scopeKey.isEmpty()) { + log.debug("[sandbox] No scope key available, skipping state persistence"); + return; + } + + try { + String json = client.serializeState(state); + stateStore.save(scopeKey.get(), json); + log.debug( + "[sandbox] Persisted sandbox state for scope {}: sessionId={}", + scopeKey.get(), + state.getSessionId()); + } catch (Exception e) { + log.warn("[sandbox] Failed to persist sandbox state: {}", e.getMessage(), e); + } + } + + public void clearState(SandboxContext sandboxContext, RuntimeContext runtimeContext) { + Optional scopeKey = + SandboxIsolationKey.resolve( + sandboxContext != null ? sandboxContext.getIsolationScope() : null, + runtimeContext, + agentId); + if (scopeKey.isEmpty()) { + return; + } + + try { + stateStore.delete(scopeKey.get()); + } catch (Exception e) { + log.warn("[sandbox] Failed to clear sandbox state: {}", e.getMessage(), e); + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxState.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxState.java new file mode 100644 index 000000000..e19a671b8 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxState.java @@ -0,0 +1,83 @@ +/* + * 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.sandbox; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxState; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshot; + +/** + * Serializable state of a sandbox, persisted by {@link SandboxStateStore} so a sandbox can + * be resumed across calls. + * + *

    The {@link #workspaceRootReady} flag drives the 4-branch start logic: it records whether + * the workspace was fully initialized at the last stop. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes({@JsonSubTypes.Type(value = DockerSandboxState.class, name = "docker")}) +public abstract class SandboxState { + + private String sessionId; + + @JsonProperty("manifest") + private WorkspaceSpec workspaceSpec; + + private SandboxSnapshot snapshot; + private String workspaceProjectionHash; + private boolean workspaceRootReady = false; + + public String getSessionId() { + return sessionId; + } + + public void setSessionId(String sessionId) { + this.sessionId = sessionId; + } + + public WorkspaceSpec getWorkspaceSpec() { + return workspaceSpec; + } + + public void setWorkspaceSpec(WorkspaceSpec workspaceSpec) { + this.workspaceSpec = workspaceSpec; + } + + public SandboxSnapshot getSnapshot() { + return snapshot; + } + + public void setSnapshot(SandboxSnapshot snapshot) { + this.snapshot = snapshot; + } + + public String getWorkspaceProjectionHash() { + return workspaceProjectionHash; + } + + public void setWorkspaceProjectionHash(String workspaceProjectionHash) { + this.workspaceProjectionHash = workspaceProjectionHash; + } + + public boolean isWorkspaceRootReady() { + return workspaceRootReady; + } + + public void setWorkspaceRootReady(boolean workspaceRootReady) { + this.workspaceRootReady = workspaceRootReady; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxStateStore.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxStateStore.java new file mode 100644 index 000000000..330e4fce6 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxStateStore.java @@ -0,0 +1,61 @@ +/* + * 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.sandbox; + +import java.io.IOException; +import java.util.Optional; + +/** + * Storage abstraction for persisting and loading sandbox session state keyed by + * {@link SandboxIsolationKey}. + * + *

    Implementations must be safe to call from multiple threads, but they are not required to + * provide transactional or atomic semantics across concurrent writes to the same key. + * + * @see WorkspaceSandboxStateStore + */ +public interface SandboxStateStore { + + /** + * Loads the persisted sandbox state JSON for the given key. + * + * @param key the isolation key identifying the state slot + * @return the serialized {@link SandboxState} JSON, or empty if no state is stored + * @throws IOException if a storage error occurs + */ + Optional load(SandboxIsolationKey key) throws IOException; + + /** + * Saves the sandbox state JSON for the given key. + * + *

    An existing value for the same key is overwritten. + * + * @param key the isolation key identifying the state slot + * @param json the serialized {@link SandboxState} JSON + * @throws IOException if a storage error occurs + */ + void save(SandboxIsolationKey key, String json) throws IOException; + + /** + * Deletes the sandbox state for the given key. + * + *

    No-op if no state is stored for the key. + * + * @param key the isolation key identifying the state slot + * @throws IOException if a storage error occurs + */ + void delete(SandboxIsolationKey key) throws IOException; +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SessionSandboxStateStore.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SessionSandboxStateStore.java new file mode 100644 index 000000000..880649a8d --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SessionSandboxStateStore.java @@ -0,0 +1,100 @@ +/* + * 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.sandbox; + +import io.agentscope.core.session.Session; +import io.agentscope.core.state.SessionKey; +import io.agentscope.core.state.SimpleSessionKey; +import io.agentscope.core.state.State; +import io.agentscope.harness.agent.IsolationScope; +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; + +/** + * {@link SandboxStateStore} backed by the generic AgentScope {@link Session} abstraction. + * + *

    This store keeps sandbox lifecycle state in the same state backend as ReActAgent runtime + * state. As a result, providing a distributed {@link Session} implementation (for example Redis) + * automatically enables distributed sandbox resume state. + */ +public final class SessionSandboxStateStore implements SandboxStateStore { + + private static final String SANDBOX_STATE_KEY = "_sandbox_state"; + + private final Session session; + private final String agentId; + + public SessionSandboxStateStore(Session session, String agentId) { + this.session = Objects.requireNonNull(session, "session must not be null"); + this.agentId = Objects.requireNonNull(agentId, "agentId must not be null"); + } + + @Override + public Optional load(SandboxIsolationKey key) throws IOException { + try { + SessionKey slot = slotKey(key); + Optional state = + session.get(slot, SANDBOX_STATE_KEY, SandboxStateSlot.class); + if (state.isEmpty() || state.get().deleted() || state.get().json() == null) { + return Optional.empty(); + } + return Optional.of(state.get().json()); + } catch (Exception e) { + throw asIo("load", key, e); + } + } + + @Override + public void save(SandboxIsolationKey key, String json) throws IOException { + try { + session.save(slotKey(key), SANDBOX_STATE_KEY, new SandboxStateSlot(json, false)); + } catch (Exception e) { + throw asIo("save", key, e); + } + } + + @Override + public void delete(SandboxIsolationKey key) throws IOException { + try { + // Not all Session implementations support per-key delete. Tombstone keeps behavior + // consistent across backends. + session.save(slotKey(key), SANDBOX_STATE_KEY, SandboxStateSlot.tombstone()); + } catch (Exception e) { + throw asIo("delete", key, e); + } + } + + private SessionKey slotKey(SandboxIsolationKey key) { + IsolationScope scope = key.getScope(); + return switch (scope) { + case SESSION -> SimpleSessionKey.of("sandbox/session/" + key.getValue()); + case USER -> SimpleSessionKey.of("sandbox/user/" + agentId + "/" + key.getValue()); + case AGENT -> SimpleSessionKey.of("sandbox/agent/" + agentId); + case GLOBAL -> SimpleSessionKey.of("sandbox/global"); + }; + } + + private static IOException asIo(String op, SandboxIsolationKey key, Exception e) { + return new IOException("Failed to " + op + " sandbox state for " + key, e); + } + + private record SandboxStateSlot(String json, boolean deleted) implements State { + static SandboxStateSlot tombstone() { + return new SandboxStateSlot("", true); + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceArchiveExtractor.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceArchiveExtractor.java new file mode 100644 index 000000000..ea903f9bb --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceArchiveExtractor.java @@ -0,0 +1,114 @@ +/* + * 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.sandbox; + +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveInputStream; + +/** + * Securely extracts tar archives into a target directory. + * + *

    Applies strict path traversal guards before extracting each archive entry: + *

      + *
    1. Rejects entries with absolute paths (starting with {@code /} or {@code \})
    2. + *
    3. Rejects entries containing {@code ..} path segments
    4. + *
    5. Verifies the resolved destination path starts with the canonical root
    6. + *
    + * + *

    These guards defend against the "Zip Slip" class of path traversal attacks. + */ +public class WorkspaceArchiveExtractor { + + private WorkspaceArchiveExtractor() { + // Utility class, no instances + } + + /** + * Extracts a tar archive stream into the given destination directory. + * + *

    The destination directory must already exist. Existing files at the same paths + * will be replaced. + * + * @param destRoot the directory to extract into; must already exist + * @param tarStream the tar archive input stream; caller is responsible for closing + * @throws SandboxException.SandboxRuntimeException if a path traversal attempt is detected + * @throws Exception if extraction fails for any other reason + */ + public static void extractTarArchive(Path destRoot, InputStream tarStream) throws Exception { + Path canonicalRoot = destRoot.normalize().toAbsolutePath(); + + try (TarArchiveInputStream tar = new TarArchiveInputStream(tarStream)) { + TarArchiveEntry entry; + while ((entry = tar.getNextEntry()) != null) { + validateEntryName(entry.getName(), canonicalRoot); + + Path dest = canonicalRoot.resolve(entry.getName()).normalize(); + + // Final guard: verify destination is strictly within the root + if (!dest.startsWith(canonicalRoot)) { + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_ARCHIVE_READ_ERROR, + "Tar entry escapes destination root: " + entry.getName()); + } + + if (entry.isDirectory()) { + Files.createDirectories(dest); + } else { + if (dest.getParent() != null) { + Files.createDirectories(dest.getParent()); + } + Files.copy(tar, dest, StandardCopyOption.REPLACE_EXISTING); + } + } + } + } + + private static void validateEntryName(String name, Path canonicalRoot) { + if (name == null || name.isEmpty()) { + return; + } + + // Reject absolute paths + if (name.startsWith("/") || name.startsWith("\\")) { + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_ARCHIVE_READ_ERROR, + "Tar entry has absolute path (rejected): " + name); + } + + // Reject path traversal sequences + if (name.contains("..")) { + // Normalize and check for actual traversal segments + for (String segment : name.split("[/\\\\]")) { + if ("..".equals(segment)) { + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_ARCHIVE_READ_ERROR, + "Tar entry contains path traversal (rejected): " + name); + } + } + } + + // Reject null bytes + if (name.contains("\0")) { + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_ARCHIVE_READ_ERROR, + "Tar entry name contains null byte (rejected): " + name); + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceProjectionApplier.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceProjectionApplier.java new file mode 100644 index 000000000..272838fd8 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceProjectionApplier.java @@ -0,0 +1,156 @@ +/* + * 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.sandbox; + +import io.agentscope.harness.agent.sandbox.layout.DirEntry; +import io.agentscope.harness.agent.sandbox.layout.WorkspaceEntry; +import io.agentscope.harness.agent.sandbox.layout.WorkspaceProjectionEntry; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.MessageDigest; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.apache.commons.compress.archivers.tar.TarArchiveEntry; +import org.apache.commons.compress.archivers.tar.TarArchiveOutputStream; + +/** + * Builds a deterministic archive payload for workspace projection entries. + * + *

    The payload contains: + *

      + *
    • projected files as a tar archive (for sandbox hydration)
    • + *
    • a content hash used to skip unchanged projections across calls
    • + *
    + */ +public final class WorkspaceProjectionApplier { + + private WorkspaceProjectionApplier() {} + + /** + * Builds projection payload from all {@link WorkspaceProjectionEntry} items in the spec. + */ + public static ProjectionPayload build(WorkspaceSpec spec) throws Exception { + if (spec == null || spec.getEntries().isEmpty()) { + return null; + } + List entries = new ArrayList<>(); + collectProjectionEntries(spec.getEntries(), entries); + if (entries.isEmpty()) { + return null; + } + + Map projectedFiles = collectProjectedFiles(entries); + List> ordered = new ArrayList<>(projectedFiles.entrySet()); + ordered.sort(Comparator.comparing(Map.Entry::getKey)); + + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + try (TarArchiveOutputStream tar = new TarArchiveOutputStream(baos)) { + tar.setLongFileMode(TarArchiveOutputStream.LONGFILE_POSIX); + for (Map.Entry file : ordered) { + String rel = file.getKey(); + Path src = file.getValue(); + byte[] content = Files.readAllBytes(src); + + digest.update(rel.getBytes(java.nio.charset.StandardCharsets.UTF_8)); + digest.update((byte) 0); + digest.update(content); + + TarArchiveEntry entry = new TarArchiveEntry(rel); + entry.setSize(content.length); + tar.putArchiveEntry(entry); + tar.write(content); + tar.closeArchiveEntry(); + } + tar.finish(); + } + + String hash = bytesToHex(digest.digest()); + return new ProjectionPayload(hash, baos.toByteArray(), ordered.size()); + } + + private static void collectProjectionEntries( + Map map, List out) { + for (WorkspaceEntry entry : map.values()) { + if (entry instanceof WorkspaceProjectionEntry projection) { + out.add(projection); + } else if (entry instanceof DirEntry dir && !dir.getChildren().isEmpty()) { + collectProjectionEntries(dir.getChildren(), out); + } + } + } + + private static Map collectProjectedFiles(List entries) + throws IOException { + Map files = new LinkedHashMap<>(); + for (WorkspaceProjectionEntry entry : entries) { + if (entry.getSourceRoot() == null || entry.getSourceRoot().isBlank()) { + continue; + } + Path sourceRoot = Path.of(entry.getSourceRoot()).toAbsolutePath().normalize(); + for (String root : entry.getIncludeRoots()) { + if (root == null || root.isBlank()) { + continue; + } + Path resolved = sourceRoot.resolve(root).normalize(); + if (!resolved.startsWith(sourceRoot) || !Files.exists(resolved)) { + continue; + } + if (Files.isRegularFile(resolved)) { + files.put(normalizePath(root), resolved); + } else if (Files.isDirectory(resolved)) { + try (var walk = Files.walk(resolved)) { + walk.filter(Files::isRegularFile) + .forEach( + p -> { + String rel = + normalizePath( + sourceRoot.relativize(p).toString()); + files.put(rel, p); + }); + } + } + } + } + return files; + } + + private static String normalizePath(String path) { + return path.replace('\\', '/'); + } + + private static String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(bytes.length * 2); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } + + /** + * Projection result used at sandbox start. + * + * @param hash deterministic hash over projected file paths and bytes + * @param tarBytes tar archive containing projected files + * @param fileCount number of projected files + */ + public record ProjectionPayload(String hash, byte[] tarBytes, int fileCount) {} +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceSandboxStateStore.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceSandboxStateStore.java new file mode 100644 index 000000000..1ec722f26 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceSandboxStateStore.java @@ -0,0 +1,130 @@ +/* + * 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.sandbox; + +import io.agentscope.harness.agent.IsolationScope; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardOpenOption; +import java.util.Base64; +import java.util.Optional; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Filesystem-backed implementation of {@link SandboxStateStore}. + * + *

    On-disk layout (relative to {@code workspaceRoot}): + * + *

    + * SESSION scope  → agents/<agentId>/context/<safe(sessionId)>/_sandbox.json
    + * USER scope     → agents/<agentId>/sandboxes/user/<safe(userId)>.json
    + * AGENT scope    → agents/<agentId>/sandboxes/agent.json
    + * GLOBAL scope   → sandboxes/global.json
    + * 
    + * + *

    Values that contain characters outside {@code [a-zA-Z0-9_\-.]} are Base64url-encoded + * (no padding) to produce filesystem-safe filenames. + */ +public final class WorkspaceSandboxStateStore implements SandboxStateStore { + + private static final Logger log = LoggerFactory.getLogger(WorkspaceSandboxStateStore.class); + + private static final Pattern SAFE_PATTERN = Pattern.compile("^[a-zA-Z0-9_\\-.]+$"); + + private final Path workspaceRoot; + private final String agentId; + + /** + * Creates a store backed by the given workspace directory. + * + * @param workspaceRoot root of the agent workspace + * @param agentId agent name (used in path construction for all scopes) + */ + public WorkspaceSandboxStateStore(Path workspaceRoot, String agentId) { + this.workspaceRoot = workspaceRoot; + this.agentId = agentId; + } + + @Override + public Optional load(SandboxIsolationKey key) throws IOException { + Path file = resolveFile(key); + if (!Files.exists(file)) { + return Optional.empty(); + } + return Optional.of(Files.readString(file, StandardCharsets.UTF_8)); + } + + @Override + public void save(SandboxIsolationKey key, String json) throws IOException { + Path file = resolveFile(key); + Files.createDirectories(file.getParent()); + Files.writeString( + file, + json, + StandardCharsets.UTF_8, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + log.debug("[sandbox-store] Saved state to {}", file); + } + + @Override + public void delete(SandboxIsolationKey key) throws IOException { + Path file = resolveFile(key); + Files.deleteIfExists(file); + log.debug("[sandbox-store] Deleted state at {}", file); + } + + /** Resolves the file path for the given key. */ + private Path resolveFile(SandboxIsolationKey key) { + IsolationScope scope = key.getScope(); + if (scope == IsolationScope.SESSION) { + return workspaceRoot + .resolve("agents") + .resolve(agentId) + .resolve("context") + .resolve(safeName(key.getValue())) + .resolve("_sandbox.json"); + } else if (scope == IsolationScope.USER) { + return workspaceRoot + .resolve("agents") + .resolve(agentId) + .resolve("sandboxes") + .resolve("user") + .resolve(safeName(key.getValue()) + ".json"); + } else if (scope == IsolationScope.AGENT) { + return workspaceRoot + .resolve("agents") + .resolve(agentId) + .resolve("sandboxes") + .resolve("agent.json"); + } else { + return workspaceRoot.resolve("sandboxes").resolve("global.json"); + } + } + + private static String safeName(String value) { + if (SAFE_PATTERN.matcher(value).matches()) { + return value; + } + return Base64.getUrlEncoder() + .withoutPadding() + .encodeToString(value.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceSpec.java new file mode 100644 index 000000000..8d2341f48 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceSpec.java @@ -0,0 +1,84 @@ +/* + * 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.sandbox; + +import io.agentscope.harness.agent.sandbox.layout.WorkspaceEntry; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Describes the desired initial state of a sandbox workspace: root path, materialized + * file/directory entries, and per-exec environment variables. + * + *

    Fields: + *

      + *
    • {@code root} — workspace path inside the sandbox (default: {@code /workspace}) + *
    • {@code entries} — files and directories to materialize at startup + *
    • {@code environment} — environment variables to inject into every exec command + *
    + * + *

    Usage example: + *

    {@code
    + * WorkspaceSpec spec = new WorkspaceSpec();
    + * spec.setRoot("/workspace");
    + * spec.getEntries().put("README.md", new io.agentscope.harness.agent.sandbox.layout.FileEntry("# My Project"));
    + * spec.getEnvironment().put("DEBUG", "true");
    + * }
    + */ +public class WorkspaceSpec { + + private String root = "/workspace"; + private Map entries = new LinkedHashMap<>(); + private Map environment = new LinkedHashMap<>(); + + public WorkspaceSpec() {} + + public String getRoot() { + return root; + } + + public void setRoot(String root) { + this.root = root; + } + + public Map getEntries() { + return entries; + } + + public void setEntries(Map entries) { + this.entries = entries != null ? entries : new LinkedHashMap<>(); + } + + public Map getEnvironment() { + return environment; + } + + public void setEnvironment(Map environment) { + this.environment = environment != null ? environment : new LinkedHashMap<>(); + } + + /** + * Creates a deep copy. {@link WorkspaceEntry} values in the entries map are shared + * (shallow copy); they are treated as immutable once a sandbox has started. + */ + public WorkspaceSpec copy() { + WorkspaceSpec copy = new WorkspaceSpec(); + copy.root = this.root; + copy.entries = new LinkedHashMap<>(this.entries); + copy.environment = new LinkedHashMap<>(this.environment); + return copy; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceSpecApplier.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceSpecApplier.java new file mode 100644 index 000000000..c6108eddc --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/WorkspaceSpecApplier.java @@ -0,0 +1,159 @@ +/* + * 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.sandbox; + +import io.agentscope.harness.agent.sandbox.layout.DirEntry; +import io.agentscope.harness.agent.sandbox.layout.FileEntry; +import io.agentscope.harness.agent.sandbox.layout.GitRepoEntry; +import io.agentscope.harness.agent.sandbox.layout.LocalDirEntry; +import io.agentscope.harness.agent.sandbox.layout.LocalFileEntry; +import io.agentscope.harness.agent.sandbox.layout.WorkspaceEntry; +import io.agentscope.harness.agent.sandbox.layout.WorkspaceProjectionEntry; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.Map; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Applies a {@link WorkspaceSpec} to a target directory by materializing declared entries + * (files, directories, local copies). + * + *

    Supports two materialization modes: + *

      + *
    • {@code onlyEphemeral=false}: apply all entries (Branch C / D of sandbox start)
    • + *
    • {@code onlyEphemeral=true}: apply only entries where + * {@link WorkspaceEntry#isEphemeral()} is {@code true} (Branch A / B of sandbox start) + *
    • + *
    + */ +public class WorkspaceSpecApplier { + + private static final Logger log = LoggerFactory.getLogger(WorkspaceSpecApplier.class); + + private final Path workspaceRoot; + + public WorkspaceSpecApplier(String workspaceRoot) { + this.workspaceRoot = Path.of(workspaceRoot); + } + + public WorkspaceSpecApplier(Path workspaceRoot) { + this.workspaceRoot = workspaceRoot; + } + + public void applyWorkspaceSpec(WorkspaceSpec spec, boolean onlyEphemeral) throws Exception { + if (onlyEphemeral) { + applyEphemeralEntries(spec.getEntries(), workspaceRoot); + } else { + applyAllEntries(spec.getEntries(), workspaceRoot); + } + } + + private void applyAllEntries(Map entries, Path destDir) + throws Exception { + for (Map.Entry e : entries.entrySet()) { + Path dest = destDir.resolve(e.getKey()); + applyEntry(e.getValue(), dest); + } + } + + private void applyEphemeralEntries(Map entries, Path destDir) + throws Exception { + for (Map.Entry e : entries.entrySet()) { + WorkspaceEntry entry = e.getValue(); + Path dest = destDir.resolve(e.getKey()); + if (entry.isEphemeral()) { + applyEntry(entry, dest); + } else if (entry instanceof DirEntry de && !de.getChildren().isEmpty()) { + applyEphemeralEntries(de.getChildren(), dest); + } + } + } + + private void applyEntry(WorkspaceEntry entry, Path dest) throws Exception { + if (entry instanceof FileEntry fe) { + Files.createDirectories(dest.getParent()); + Charset charset = + Charset.forName(fe.getEncoding() != null ? fe.getEncoding() : "UTF-8"); + Files.writeString( + dest, + fe.getContent() != null ? fe.getContent() : "", + charset, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING); + + } else if (entry instanceof DirEntry de) { + Files.createDirectories(dest); + for (Map.Entry child : de.getChildren().entrySet()) { + applyEntry(child.getValue(), dest.resolve(child.getKey())); + } + + } else if (entry instanceof LocalFileEntry lf) { + if (lf.getSourcePath() == null || lf.getSourcePath().isBlank()) { + log.warn("LocalFileEntry has null/blank sourcePath, skipping: {}", dest); + return; + } + Path source = Path.of(lf.getSourcePath()).toAbsolutePath().normalize(); + Files.createDirectories(dest.getParent()); + Files.copy(source, dest, StandardCopyOption.REPLACE_EXISTING); + + } else if (entry instanceof LocalDirEntry ld) { + if (ld.getSourcePath() == null || ld.getSourcePath().isBlank()) { + log.warn("LocalDirEntry has null/blank sourcePath, skipping: {}", dest); + return; + } + Path source = Path.of(ld.getSourcePath()).toAbsolutePath().normalize(); + copyDirectory(source, dest); + + } else if (entry instanceof GitRepoEntry) { + log.warn("GitRepoEntry is not yet implemented, skipping entry at: {}", dest); + } else if (entry instanceof WorkspaceProjectionEntry) { + log.debug( + "WorkspaceProjectionEntry is applied by sandbox lifecycle, skipping: {}", dest); + } + } + + private void copyDirectory(Path source, Path dest) throws Exception { + if (!Files.isDirectory(source)) { + throw new IOException("LocalDirEntry sourcePath is not a directory: " + source); + } + try (Stream walk = Files.walk(source)) { + walk.forEach( + src -> { + Path rel = source.relativize(src); + Path target = dest.resolve(rel); + try { + if (Files.isDirectory(src)) { + Files.createDirectories(target); + } else { + Files.createDirectories(target.getParent()); + Files.copy(src, target, StandardCopyOption.REPLACE_EXISTING); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } catch (UncheckedIOException e) { + throw e.getCause(); + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/filesystem/DockerFilesystemSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/filesystem/DockerFilesystemSpec.java new file mode 100644 index 000000000..a6f563537 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/filesystem/DockerFilesystemSpec.java @@ -0,0 +1,118 @@ +/* + * 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.sandbox.filesystem; + +import io.agentscope.harness.agent.sandbox.SandboxClient; +import io.agentscope.harness.agent.sandbox.SandboxClientOptions; +import io.agentscope.harness.agent.sandbox.WorkspaceSpec; +import io.agentscope.harness.agent.sandbox.impl.docker.DockerSandboxClientOptions; +import io.agentscope.harness.agent.sandbox.snapshot.NoopSnapshotSpec; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Sandbox filesystem spec for Docker backend. + */ +public class DockerFilesystemSpec extends SandboxFilesystemSpec { + + private SandboxClient client; + private final DockerSandboxClientOptions options = new DockerSandboxClientOptions(); + private SandboxSnapshotSpec snapshotSpec = new NoopSnapshotSpec(); + private WorkspaceSpec defaultWorkspaceSpec = new WorkspaceSpec(); + + public DockerFilesystemSpec client(SandboxClient client) { + this.client = client; + return this; + } + + public DockerFilesystemSpec image(String image) { + options.image(image); + return this; + } + + public DockerFilesystemSpec workspaceRoot(String workspaceRoot) { + options.workspaceRoot(workspaceRoot); + return this; + } + + public DockerFilesystemSpec environment(Map environment) { + options.setEnvironment( + environment != null ? new LinkedHashMap<>(environment) : new LinkedHashMap<>()); + return this; + } + + public DockerFilesystemSpec memorySizeBytes(Long memorySizeBytes) { + options.memorySizeBytes(memorySizeBytes); + return this; + } + + public DockerFilesystemSpec cpuCount(Long cpuCount) { + options.cpuCount(cpuCount); + return this; + } + + public DockerFilesystemSpec exposedPorts(int... exposedPorts) { + options.exposedPorts(exposedPorts); + return this; + } + + public DockerFilesystemSpec network(String network) { + options.network(network); + return this; + } + + public DockerFilesystemSpec additionalRunArgs(String... additionalRunArgs) { + options.additionalRunArgs(additionalRunArgs); + return this; + } + + public DockerFilesystemSpec additionalRunArgs(List additionalRunArgs) { + options.setAdditionalRunArgs(additionalRunArgs); + return this; + } + + public DockerFilesystemSpec snapshotSpec(SandboxSnapshotSpec snapshotSpec) { + this.snapshotSpec = snapshotSpec; + return this; + } + + public DockerFilesystemSpec workspaceSpec(WorkspaceSpec workspaceSpec) { + this.defaultWorkspaceSpec = workspaceSpec; + return this; + } + + @Override + protected SandboxClient createClient() { + return client != null ? client : options.createClient(); + } + + @Override + protected SandboxClientOptions clientOptions() { + return options; + } + + @Override + protected SandboxSnapshotSpec snapshotSpec() { + return snapshotSpec; + } + + @Override + protected WorkspaceSpec workspaceSpec() { + return defaultWorkspaceSpec; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/filesystem/SandboxFilesystemSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/filesystem/SandboxFilesystemSpec.java new file mode 100644 index 000000000..50f4aa287 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/filesystem/SandboxFilesystemSpec.java @@ -0,0 +1,139 @@ +/* + * 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.sandbox.filesystem; + +import io.agentscope.harness.agent.IsolationScope; +import io.agentscope.harness.agent.sandbox.SandboxClient; +import io.agentscope.harness.agent.sandbox.SandboxClientOptions; +import io.agentscope.harness.agent.sandbox.SandboxContext; +import io.agentscope.harness.agent.sandbox.SandboxStateStore; +import io.agentscope.harness.agent.sandbox.WorkspaceSpec; +import io.agentscope.harness.agent.sandbox.layout.WorkspaceEntry; +import io.agentscope.harness.agent.sandbox.layout.WorkspaceProjectionEntry; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; +import java.nio.file.Path; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Declarative sandbox filesystem configuration. + * + *

    Unlike {@code AbstractFilesystem}, this type is not a runtime filesystem implementation. + * It only describes how to create a sandbox-backed filesystem at build time. + */ +public abstract class SandboxFilesystemSpec { + + private static final List DEFAULT_WORKSPACE_PROJECTION_ROOTS = + List.of("AGENTS.md", "skills", "subagents", "knowledge"); + + private IsolationScope isolationScope; + private SandboxSnapshotSpec snapshotSpecOverride; + private SandboxStateStore sandboxStateStore; + private boolean workspaceProjectionEnabled = true; + private List workspaceProjectionRoots = DEFAULT_WORKSPACE_PROJECTION_ROOTS; + + protected abstract SandboxClient createClient(); + + protected abstract SandboxClientOptions clientOptions(); + + protected abstract SandboxSnapshotSpec snapshotSpec(); + + protected abstract WorkspaceSpec workspaceSpec(); + + public SandboxFilesystemSpec isolationScope(IsolationScope scope) { + this.isolationScope = scope; + return this; + } + + public IsolationScope getIsolationScope() { + return isolationScope; + } + + public SandboxFilesystemSpec snapshotSpec(SandboxSnapshotSpec snapshotSpec) { + this.snapshotSpecOverride = snapshotSpec; + return this; + } + + public SandboxSnapshotSpec getSnapshotSpecOverride() { + return snapshotSpecOverride; + } + + /** + * Overrides the {@link SandboxStateStore} used to persist and resume sandbox metadata across + * calls. When {@code null} (default), {@link io.agentscope.harness.agent.HarnessAgent} uses + * {@link io.agentscope.harness.agent.sandbox.SessionSandboxStateStore} with the effective + * {@link io.agentscope.core.session.Session} and agent id at build time. + * + * @param sandboxStateStore custom store, or {@code null} for the default session-backed store + * @return this spec + */ + public SandboxFilesystemSpec sandboxStateStore(SandboxStateStore sandboxStateStore) { + this.sandboxStateStore = sandboxStateStore; + return this; + } + + public SandboxStateStore getSandboxStateStore() { + return sandboxStateStore; + } + + public SandboxFilesystemSpec workspaceProjectionEnabled(boolean enabled) { + this.workspaceProjectionEnabled = enabled; + return this; + } + + public SandboxFilesystemSpec workspaceProjectionRoots(List includeRoots) { + this.workspaceProjectionRoots = + includeRoots != null + ? List.copyOf(includeRoots) + : DEFAULT_WORKSPACE_PROJECTION_ROOTS; + return this; + } + + public final SandboxContext toSandboxContext(Path hostWorkspaceRoot) { + SandboxClient client = + Objects.requireNonNull(createClient(), "sandbox client is required"); + WorkspaceSpec withProjection = buildWorkspaceSpecWithProjection(hostWorkspaceRoot); + return SandboxContext.builder() + .client(client) + .clientOptions(clientOptions()) + .snapshotSpec(snapshotSpecOverride != null ? snapshotSpecOverride : snapshotSpec()) + .workspaceSpec(withProjection) + .isolationScope(isolationScope) + .build(); + } + + public final SandboxContext toSandboxContext() { + return toSandboxContext(null); + } + + private WorkspaceSpec buildWorkspaceSpecWithProjection(Path hostWorkspaceRoot) { + WorkspaceSpec base = workspaceSpec(); + WorkspaceSpec effective = base != null ? base.copy() : new WorkspaceSpec(); + if (!workspaceProjectionEnabled || hostWorkspaceRoot == null) { + return effective; + } + WorkspaceProjectionEntry projection = new WorkspaceProjectionEntry(); + projection.setSourceRoot(hostWorkspaceRoot.toAbsolutePath().normalize().toString()); + projection.setIncludeRoots(workspaceProjectionRoots); + + Map entries = new LinkedHashMap<>(effective.getEntries()); + entries.put("__workspace_projection__", projection); + effective.setEntries(entries); + return effective; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandbox.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandbox.java new file mode 100644 index 000000000..537339d2b --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandbox.java @@ -0,0 +1,606 @@ +/* + * 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.sandbox.impl.docker; + +import io.agentscope.harness.agent.sandbox.AbstractBaseSandbox; +import io.agentscope.harness.agent.sandbox.ExecResult; +import io.agentscope.harness.agent.sandbox.SandboxErrorCode; +import io.agentscope.harness.agent.sandbox.SandboxException; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Docker {@link io.agentscope.harness.agent.sandbox.Sandbox} that runs commands in a container. + * + *

    Uses the {@code docker} CLI via {@link ProcessBuilder}; no docker-java library + * dependency is required. The Docker daemon must be accessible on the host. + * + *

    Container Lifecycle

    + *
      + *
    • On {@link #start()}: the container is created and started if it does not exist; + * if the container exists but is stopped it is restarted; if it is already running + * the existing container is reused.
    • + *
    • On {@link #stop()}: the workspace snapshot is persisted (if configured). + * The container keeps running.
    • + *
    • On {@link #shutdown()}: the container is stopped and removed if self-managed.
    • + *
    + * + *

    Workspace Operations

    + *
      + *
    • Exec: {@code docker exec -w sh -c }
    • + *
    • PersistWorkspace: {@code docker exec tar -cf - -C .}
    • + *
    • HydrateWorkspace: {@code docker exec -i tar -xf - -C }
    • + *
    + */ +public class DockerSandbox extends AbstractBaseSandbox { + + private static final Logger log = LoggerFactory.getLogger(DockerSandbox.class); + + private static final int OUTPUT_TRUNCATE_BYTES = 512 * 1024; // 512 KB per stream + private static final int CONTAINER_START_TIMEOUT_SECONDS = 60; + private static final int CONTAINER_STOP_TIMEOUT_SECONDS = 30; + private static final int TAR_TIMEOUT_SECONDS = 120; + + private final DockerSandboxState dockerState; + + public DockerSandbox(DockerSandboxState state) { + super(state); + this.dockerState = state; + } + + /** + * Ensures the backing Docker container is running before executing the standard + * 4-branch workspace start logic. + * + * @throws Exception if the container cannot be started + */ + @Override + public void start() throws Exception { + doEnsureContainerRunning(); + super.start(); + } + + /** + * Stops and removes the Docker container if self-managed. + * + * @throws Exception if the container cannot be stopped or removed + */ + @Override + public void shutdown() throws Exception { + String containerId = dockerState.getContainerId(); + if (containerId == null || containerId.isBlank()) { + return; + } + if (!dockerState.isContainerOwned()) { + log.debug( + "[sandbox-docker] Skipping shutdown: container is user-managed: {}", + containerId); + return; + } + try { + runDockerCliBlocking( + CONTAINER_STOP_TIMEOUT_SECONDS * 2, + "docker", + "stop", + "--time=" + CONTAINER_STOP_TIMEOUT_SECONDS, + containerId); + log.debug("[sandbox-docker] Container stopped: {}", containerId); + } catch (Exception e) { + log.warn( + "[sandbox-docker] Failed to stop container {}: {}", + containerId, + e.getMessage()); + } + try { + runDockerCliBlocking(30, "docker", "rm", "--force", containerId); + log.debug("[sandbox-docker] Container removed: {}", containerId); + } catch (Exception e) { + log.warn( + "[sandbox-docker] Failed to remove container {}: {}", + containerId, + e.getMessage()); + } + } + + @Override + protected ExecResult doExec(String command, int timeoutSeconds) throws Exception { + String containerId = dockerState.getContainerId(); + String workspaceRoot = dockerState.getWorkspaceRoot(); + + List cmd = new ArrayList<>(); + cmd.add("docker"); + cmd.add("exec"); + cmd.add("-w"); + cmd.add(workspaceRoot); + cmd.add(containerId); + cmd.add("sh"); + cmd.add("-c"); + cmd.add(command); + + ProcessBuilder pb = new ProcessBuilder(cmd); + Process process = pb.start(); + + ExecutorService drainer = + Executors.newFixedThreadPool( + 2, + r -> { + Thread t = + new Thread( + r, + "sandbox-docker-drain-" + dockerState.getSessionId()); + t.setDaemon(true); + return t; + }); + + Future stdoutFuture = + drainer.submit(() -> readStream(process.getInputStream(), OUTPUT_TRUNCATE_BYTES)); + Future stderrFuture = + drainer.submit(() -> readStream(process.getErrorStream(), OUTPUT_TRUNCATE_BYTES)); + drainer.shutdown(); + + boolean exited = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); + if (!exited) { + process.destroyForcibly(); + drainer.shutdownNow(); + throw new SandboxException.ExecTimeoutException(command, timeoutSeconds); + } + + String stdout = stdoutFuture.get(); + String stderr = stderrFuture.get(); + int exitCode = process.exitValue(); + + boolean truncated = + stdout.length() >= OUTPUT_TRUNCATE_BYTES + || stderr.length() >= OUTPUT_TRUNCATE_BYTES; + ExecResult result = new ExecResult(exitCode, stdout, stderr, truncated); + if (!result.ok()) { + throw new SandboxException.ExecException(exitCode, stdout, stderr); + } + return result; + } + + @Override + protected InputStream doPersistWorkspace() throws Exception { + String containerId = dockerState.getContainerId(); + String workspaceRoot = dockerState.getWorkspaceRoot(); + + ProcessBuilder pb = + new ProcessBuilder( + "docker", "exec", containerId, "tar", "-cf", "-", "-C", workspaceRoot, "."); + + Process process = pb.start(); + + ExecutorService stderrDrainer = + Executors.newSingleThreadExecutor( + r -> { + Thread t = + new Thread( + r, + "sandbox-docker-tar-stderr-" + + dockerState.getSessionId()); + t.setDaemon(true); + return t; + }); + Future stderrFuture = + stderrDrainer.submit( + () -> readStream(process.getErrorStream(), OUTPUT_TRUNCATE_BYTES)); + stderrDrainer.shutdown(); + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + process.getInputStream().transferTo(buffer); + + boolean exited = process.waitFor(TAR_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!exited) { + process.destroyForcibly(); + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_ARCHIVE_WRITE_ERROR, + "docker tar command timed out for container: " + containerId); + } + + int exitCode = process.exitValue(); + if (exitCode != 0) { + String stderr = stderrFuture.get(); + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_ARCHIVE_WRITE_ERROR, + "docker tar command failed (exit=" + exitCode + "): " + stderr); + } + + return new ByteArrayInputStream(buffer.toByteArray()); + } + + @Override + protected void doHydrateWorkspace(InputStream archive) throws Exception { + String containerId = dockerState.getContainerId(); + String workspaceRoot = dockerState.getWorkspaceRoot(); + + // Ensure the workspace directory exists inside the container + runDockerCliBlocking(30, "docker", "exec", containerId, "mkdir", "-p", workspaceRoot); + + // Pipe the tar archive into the container via docker exec stdin + ProcessBuilder pb = + new ProcessBuilder( + "docker", + "exec", + "-i", + containerId, + "tar", + "-xf", + "-", + "-C", + workspaceRoot); + + Process process = pb.start(); + + ExecutorService ioExecutor = + Executors.newFixedThreadPool( + 2, + r -> { + Thread t = + new Thread( + r, + "sandbox-docker-hydrate-" + dockerState.getSessionId()); + t.setDaemon(true); + return t; + }); + + // Write archive to process stdin in background + Future writeFuture = + ioExecutor.submit( + () -> { + try (OutputStream stdin = process.getOutputStream()) { + archive.transferTo(stdin); + } catch (IOException e) { + log.warn( + "[sandbox-docker] Error writing archive to container stdin", + e); + } + return null; + }); + + Future stderrFuture = + ioExecutor.submit( + () -> readStream(process.getErrorStream(), OUTPUT_TRUNCATE_BYTES)); + ioExecutor.shutdown(); + + writeFuture.get(TAR_TIMEOUT_SECONDS, TimeUnit.SECONDS); + + boolean exited = process.waitFor(TAR_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!exited) { + process.destroyForcibly(); + ioExecutor.shutdownNow(); + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_ARCHIVE_READ_ERROR, + "docker tar extract timed out for container: " + containerId); + } + + int exitCode = process.exitValue(); + if (exitCode != 0) { + String stderr = stderrFuture.get(); + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_ARCHIVE_READ_ERROR, + "docker tar extract failed (exit=" + exitCode + "): " + stderr); + } + } + + @Override + protected void doSetupWorkspace() throws Exception { + String containerId = dockerState.getContainerId(); + String workspaceRoot = dockerState.getWorkspaceRoot(); + runDockerCliBlocking(30, "docker", "exec", containerId, "mkdir", "-p", workspaceRoot); + } + + @Override + protected void doDestroyWorkspace() throws Exception { + String containerId = dockerState.getContainerId(); + String workspaceRoot = dockerState.getWorkspaceRoot(); + if (containerId != null && !containerId.isBlank()) { + try { + runDockerCliBlocking(30, "docker", "exec", containerId, "rm", "-rf", workspaceRoot); + } catch (Exception e) { + log.warn( + "[sandbox-docker] Failed to destroy workspace {} in container {}: {}", + workspaceRoot, + containerId, + e.getMessage()); + } + } + } + + @Override + protected String getWorkspaceRoot() { + return dockerState.getWorkspaceRoot(); + } + + // ----------------------------------------------------------------- + // Container management + // ----------------------------------------------------------------- + + /** + * Ensures the Docker container is running. + * + *

    Priority: + *

      + *
    1. If container exists and is running — reuse it.
    2. + *
    3. If container exists and is stopped — restart it.
    4. + *
    5. If container is missing or unknown — create a new container, reset + * {@code workspaceRootReady} to force full workspace reinitialisation.
    6. + *
    + */ + private void doEnsureContainerRunning() throws Exception { + String containerId = dockerState.getContainerId(); + + if (containerId != null && !containerId.isBlank()) { + ContainerState state = inspectContainerState(containerId); + if (state == ContainerState.RUNNING) { + log.debug("[sandbox-docker] Container already running: {}", containerId); + return; + } else if (state == ContainerState.STOPPED) { + log.debug("[sandbox-docker] Restarting stopped container: {}", containerId); + runDockerCliBlocking( + CONTAINER_START_TIMEOUT_SECONDS, "docker", "start", containerId); + return; + } + // Container not found — fall through to create a new one + log.warn("[sandbox-docker] Container {} not found, creating a new one", containerId); + dockerState.setWorkspaceRootReady(false); + } + + createAndStartContainer(); + } + + private void createAndStartContainer() throws Exception { + String containerName = "agentscope-sandbox-" + dockerState.getSessionId(); + dockerState.setContainerName(containerName); + + List cmd = buildDockerRunCommand(containerName); + + log.debug( + "[sandbox-docker] Creating container: image={}, name={}", + dockerState.getImage(), + containerName); + + ProcessBuilder pb = new ProcessBuilder(cmd); + Process process = pb.start(); + + ExecutorService drainer = + Executors.newFixedThreadPool( + 2, + r -> { + Thread t = + new Thread( + r, + "sandbox-docker-create-" + dockerState.getSessionId()); + t.setDaemon(true); + return t; + }); + + Future stdoutFuture = + drainer.submit(() -> readStream(process.getInputStream(), 64 * 1024)); + Future stderrFuture = + drainer.submit(() -> readStream(process.getErrorStream(), 64 * 1024)); + drainer.shutdown(); + + boolean exited = process.waitFor(CONTAINER_START_TIMEOUT_SECONDS, TimeUnit.SECONDS); + if (!exited) { + process.destroyForcibly(); + drainer.shutdownNow(); + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_START_ERROR, + "docker run timed out for image: " + dockerState.getImage()); + } + + int exitCode = process.exitValue(); + String stdout = stdoutFuture.get().trim(); + String stderr = stderrFuture.get().trim(); + + if (exitCode != 0) { + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_START_ERROR, + "docker run failed (exit=" + exitCode + "): " + stderr); + } + + // stdout is the new container ID + String newContainerId = stdout.isBlank() ? null : stdout; + if (newContainerId == null) { + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_START_ERROR, + "docker run produced no container ID. stderr: " + stderr); + } + + dockerState.setContainerId(newContainerId); + log.info( + "[sandbox-docker] Container started: id={}, name={}", + newContainerId, + containerName); + } + + private List buildDockerRunCommand(String containerName) { + List cmd = new ArrayList<>(); + cmd.add("docker"); + cmd.add("run"); + cmd.add("-d"); + cmd.add("--name"); + cmd.add(containerName); + + // Environment variables from manifest + Map manifestEnv = + getState().getWorkspaceSpec() != null + ? getState().getWorkspaceSpec().getEnvironment() + : null; + if (manifestEnv != null) { + for (Map.Entry entry : manifestEnv.entrySet()) { + cmd.add("-e"); + cmd.add(entry.getKey() + "=" + entry.getValue()); + } + } + + // Memory limit + if (dockerState.getMemorySizeBytes() != null) { + cmd.add("--memory=" + dockerState.getMemorySizeBytes()); + } + + // CPU limit + if (dockerState.getCpuCount() != null) { + cmd.add("--cpus=" + dockerState.getCpuCount()); + } + + // Exposed ports (host:container mapping using same port number) + if (dockerState.getExposedPorts() != null) { + for (int port : dockerState.getExposedPorts()) { + cmd.add("-p"); + cmd.add(port + ":" + port); + } + } + + String network = dockerState.getNetwork(); + cmd.add("--network=" + (network == null || network.isBlank() ? "none" : network)); + + if (dockerState.getAdditionalRunArgs() != null) { + cmd.addAll(dockerState.getAdditionalRunArgs()); + } + + cmd.add(dockerState.getImage()); + // Keep the container alive with an idle shell loop + cmd.add("sh"); + cmd.add("-c"); + cmd.add("while :; do sleep 3600; done"); + + return cmd; + } + + /** + * Inspects a container and returns whether it is running, stopped, or unknown. + */ + private ContainerState inspectContainerState(String containerId) { + try { + ProcessBuilder pb = + new ProcessBuilder( + "docker", "inspect", "-f", "{{.State.Running}}", containerId); + Process process = pb.start(); + + Future stdoutFuture; + ExecutorService drainer = + Executors.newSingleThreadExecutor( + r -> { + Thread t = new Thread(r, "sandbox-docker-inspect"); + t.setDaemon(true); + return t; + }); + stdoutFuture = drainer.submit(() -> readStream(process.getInputStream(), 1024)); + drainer.shutdown(); + + boolean exited = process.waitFor(10, TimeUnit.SECONDS); + if (!exited) { + process.destroyForcibly(); + return ContainerState.UNKNOWN; + } + if (process.exitValue() != 0) { + return ContainerState.UNKNOWN; + } + String output = stdoutFuture.get().trim(); + return "true".equals(output) ? ContainerState.RUNNING : ContainerState.STOPPED; + } catch (Exception e) { + log.debug( + "[sandbox-docker] Failed to inspect container {}: {}", + containerId, + e.getMessage()); + return ContainerState.UNKNOWN; + } + } + + /** + * Runs a Docker CLI command, blocking until completion. + * + * @param timeoutSeconds maximum time to wait + * @param command command and arguments + * @throws SandboxException.SandboxRuntimeException if the command fails or times out + */ + private void runDockerCliBlocking(int timeoutSeconds, String... command) throws Exception { + ProcessBuilder pb = new ProcessBuilder(command); + Process process = pb.start(); + + ExecutorService drainer = + Executors.newFixedThreadPool( + 2, + r -> { + Thread t = new Thread(r, "sandbox-docker-cli"); + t.setDaemon(true); + return t; + }); + Future stderrFuture = + drainer.submit(() -> readStream(process.getErrorStream(), 64 * 1024)); + drainer.submit(() -> readStream(process.getInputStream(), 64 * 1024)); + drainer.shutdown(); + + boolean exited = process.waitFor(timeoutSeconds, TimeUnit.SECONDS); + if (!exited) { + process.destroyForcibly(); + drainer.shutdownNow(); + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_START_ERROR, + "docker command timed out: " + command[0] + " " + command[1]); + } + int exitCode = process.exitValue(); + if (exitCode != 0) { + String stderr = stderrFuture.get(); + throw new SandboxException.SandboxRuntimeException( + SandboxErrorCode.WORKSPACE_START_ERROR, + "docker command failed (exit=" + exitCode + "): " + stderr); + } + } + + /** + * Reads an InputStream into a String, truncating at {@code maxBytes}. + */ + private static String readStream(InputStream in, int maxBytes) { + try { + byte[] buf = new byte[maxBytes]; + int total = 0; + int read; + while (total < maxBytes && (read = in.read(buf, total, maxBytes - total)) != -1) { + total += read; + } + // Drain remaining bytes to prevent blocking + if (total == maxBytes) { + //noinspection ResultOfMethodCallIgnored + in.skip(Long.MAX_VALUE); + } + return new String(buf, 0, total, StandardCharsets.UTF_8); + } catch (IOException e) { + return ""; + } + } + + /** Container state as determined by {@code docker inspect}. */ + private enum ContainerState { + RUNNING, + STOPPED, + UNKNOWN + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxClient.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxClient.java new file mode 100644 index 000000000..5e246dab1 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxClient.java @@ -0,0 +1,132 @@ +/* + * 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.sandbox.impl.docker; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.jsontype.BasicPolymorphicTypeValidator; +import io.agentscope.harness.agent.sandbox.Sandbox; +import io.agentscope.harness.agent.sandbox.SandboxClient; +import io.agentscope.harness.agent.sandbox.SandboxException; +import io.agentscope.harness.agent.sandbox.SandboxState; +import io.agentscope.harness.agent.sandbox.WorkspaceSpec; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; +import java.util.UUID; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * {@link SandboxClient} implementation for the Docker sandbox backend. + * + *

    Creates and manages Docker containers via the {@code docker} CLI. The Docker daemon must + * be accessible from the host's {@code PATH}. + */ +public class DockerSandboxClient implements SandboxClient { + + private static final Logger log = LoggerFactory.getLogger(DockerSandboxClient.class); + + private final ObjectMapper objectMapper; + + public DockerSandboxClient() { + this.objectMapper = + new ObjectMapper() + .findAndRegisterModules() + .activateDefaultTyping( + BasicPolymorphicTypeValidator.builder() + .allowIfSubType("io.agentscope.harness") + .build(), + ObjectMapper.DefaultTyping.NON_FINAL); + } + + public DockerSandboxClient(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public Sandbox create( + WorkspaceSpec workspaceSpec, + SandboxSnapshotSpec snapshotSpec, + DockerSandboxClientOptions options) { + String sessionId = UUID.randomUUID().toString(); + + String image = + options != null && options.getImage() != null ? options.getImage() : "ubuntu:22.04"; + String workspaceRoot = + options != null && options.getWorkspaceRoot() != null + ? options.getWorkspaceRoot() + : "/workspace"; + + DockerSandboxState state = new DockerSandboxState(); + state.setSessionId(sessionId); + state.setWorkspaceSpec(workspaceSpec); + state.setImage(image); + state.setWorkspaceRoot(workspaceRoot); + state.setContainerOwned(true); + state.setWorkspaceRootReady(false); + + if (options != null) { + state.setMemorySizeBytes(options.getMemorySizeBytes()); + state.setCpuCount(options.getCpuCount()); + state.setExposedPorts(options.getExposedPorts()); + state.setNetwork(options.getNetwork()); + state.setAdditionalRunArgs(options.getAdditionalRunArgs()); + } + + if (snapshotSpec != null) { + state.setSnapshot(snapshotSpec.build(sessionId)); + } + + log.debug("[sandbox-docker] Creating new sandbox: id={}, image={}", sessionId, image); + return new DockerSandbox(state); + } + + @Override + public Sandbox resume(SandboxState state) { + if (!(state instanceof DockerSandboxState dockerState)) { + throw new IllegalArgumentException( + "Expected DockerSandboxState but got: " + state.getClass().getName()); + } + log.debug( + "[sandbox-docker] Resuming sandbox: id={}, containerId={}", + dockerState.getSessionId(), + dockerState.getContainerId()); + return new DockerSandbox(dockerState); + } + + @Override + public void delete(Sandbox sandbox) { + // No-op: cleanup is handled by DockerSandbox.shutdown() + } + + @Override + public String serializeState(SandboxState state) { + try { + return objectMapper.writeValueAsString(state); + } catch (Exception e) { + throw new SandboxException.SandboxConfigurationException( + "Failed to serialize Docker sandbox state", e); + } + } + + @Override + public SandboxState deserializeState(String json) { + try { + return objectMapper.readValue(json, SandboxState.class); + } catch (Exception e) { + throw new SandboxException.SandboxConfigurationException( + "Failed to deserialize Docker sandbox state", e); + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxClientOptions.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxClientOptions.java new file mode 100644 index 000000000..42381f84d --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxClientOptions.java @@ -0,0 +1,317 @@ +/* + * 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.sandbox.impl.docker; + +import io.agentscope.harness.agent.sandbox.SandboxClient; +import io.agentscope.harness.agent.sandbox.SandboxClientOptions; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +/** + * Configuration options for the Docker sandbox client. + * + *

    Describes the Docker container configuration used when creating or resuming + * a {@link io.agentscope.harness.agent.sandbox.impl.docker.DockerSandbox}. The Docker CLI must be + * available on the host + * system's {@code PATH}. + */ +public class DockerSandboxClientOptions extends SandboxClientOptions { + + /** Docker image to run. Defaults to {@code ubuntu:22.04}. */ + private String image = "ubuntu:22.04"; + + /** Workspace root path inside the container. Defaults to {@code /workspace}. */ + private String workspaceRoot = "/workspace"; + + /** Environment variables to inject into the container. */ + private Map environment = new LinkedHashMap<>(); + + /** Optional memory limit in bytes (e.g. {@code 512 * 1024 * 1024L} for 512 MB). */ + private Long memorySizeBytes; + + /** Optional CPU count limit (e.g. {@code 2L} for two CPUs). */ + private Long cpuCount; + + /** Host ports to expose from the container ({@code hostPort:containerPort} mapping). */ + private int[] exposedPorts = {}; + + /** Additional raw arguments appended to {@code docker run} before the image name. */ + private List additionalRunArgs = new ArrayList<>(); + + /** Docker network mode or network name passed to {@code docker run --network}. */ + private String network; + + @Override + public String getType() { + return "docker"; + } + + /** + * Creates a {@link DockerSandboxClient} for these options. + * + * @return new Docker sandbox client + */ + @Override + public SandboxClient createClient() { + return new DockerSandboxClient(); + } + + /** + * Returns the Docker image name. + * + * @return Docker image + */ + public String getImage() { + return image; + } + + /** + * Sets the Docker image name. + * + * @param image Docker image (e.g. {@code python:3.12-slim}) + * @return this options instance + */ + public DockerSandboxClientOptions image(String image) { + this.image = image; + return this; + } + + /** + * Sets the Docker image name. + * + * @param image Docker image + */ + public void setImage(String image) { + this.image = image; + } + + /** + * Returns the workspace root path inside the container. + * + * @return workspace root + */ + public String getWorkspaceRoot() { + return workspaceRoot; + } + + /** + * Sets the workspace root path inside the container. + * + * @param workspaceRoot absolute path inside the container + * @return this options instance + */ + public DockerSandboxClientOptions workspaceRoot(String workspaceRoot) { + this.workspaceRoot = workspaceRoot; + return this; + } + + /** + * Sets the workspace root path inside the container. + * + * @param workspaceRoot absolute path inside the container + */ + public void setWorkspaceRoot(String workspaceRoot) { + this.workspaceRoot = workspaceRoot; + } + + /** + * Returns the container environment variables. + * + * @return environment variable map + */ + public Map getEnvironment() { + return environment; + } + + /** + * Sets the container environment variables. + * + * @param environment key-value pairs + */ + public void setEnvironment(Map environment) { + this.environment = environment != null ? environment : new LinkedHashMap<>(); + } + + /** + * Returns the optional memory limit in bytes. + * + * @return memory limit or {@code null} if not set + */ + public Long getMemorySizeBytes() { + return memorySizeBytes; + } + + /** + * Sets the memory limit in bytes. + * + * @param memorySizeBytes memory limit (e.g. {@code 512 * 1024 * 1024L}) + * @return this options instance + */ + public DockerSandboxClientOptions memorySizeBytes(Long memorySizeBytes) { + this.memorySizeBytes = memorySizeBytes; + return this; + } + + /** + * Sets the memory limit in bytes. + * + * @param memorySizeBytes memory limit in bytes + */ + public void setMemorySizeBytes(Long memorySizeBytes) { + this.memorySizeBytes = memorySizeBytes; + } + + /** + * Returns the optional CPU count limit. + * + * @return CPU count or {@code null} if not set + */ + public Long getCpuCount() { + return cpuCount; + } + + /** + * Sets the CPU count limit. + * + * @param cpuCount number of CPUs (e.g. {@code 2L}) + * @return this options instance + */ + public DockerSandboxClientOptions cpuCount(Long cpuCount) { + this.cpuCount = cpuCount; + return this; + } + + /** + * Sets the CPU count limit. + * + * @param cpuCount number of CPUs + */ + public void setCpuCount(Long cpuCount) { + this.cpuCount = cpuCount; + } + + /** + * Returns the host ports to expose. + * + * @return exposed ports array + */ + public int[] getExposedPorts() { + return exposedPorts; + } + + /** + * Sets the host ports to expose from the container. + * + * @param exposedPorts port numbers + * @return this options instance + */ + public DockerSandboxClientOptions exposedPorts(int... exposedPorts) { + this.exposedPorts = exposedPorts; + return this; + } + + /** + * Sets the host ports to expose from the container. + * + * @param exposedPorts port numbers + */ + public void setExposedPorts(int[] exposedPorts) { + this.exposedPorts = exposedPorts != null ? exposedPorts : new int[0]; + } + + /** + * Returns the docker network mode or network name. + * + * @return docker network value, or {@code null} when unset + */ + public String getNetwork() { + return network; + } + + /** + * Sets the docker network mode or network name. + * + * @param network docker network value + * @return this options instance + */ + public DockerSandboxClientOptions network(String network) { + this.network = normalizeNetwork(network); + return this; + } + + /** + * Sets the docker network mode or network name. + * + * @param network docker network value + */ + public void setNetwork(String network) { + this.network = normalizeNetwork(network); + } + + /** + * Returns additional raw arguments appended to {@code docker run}. + * + * @return additional docker run arguments + */ + public List getAdditionalRunArgs() { + return additionalRunArgs; + } + + /** + * Appends additional raw arguments to {@code docker run} before the image name. + * + * @param additionalRunArgs additional docker run arguments + * @return this options instance + */ + public DockerSandboxClientOptions additionalRunArgs(String... additionalRunArgs) { + if (additionalRunArgs == null) { + return this; + } + for (String additionalRunArg : additionalRunArgs) { + if (additionalRunArg != null && !additionalRunArg.isBlank()) { + this.additionalRunArgs.add(additionalRunArg); + } + } + return this; + } + + /** + * Sets the additional raw arguments appended to {@code docker run}. + * + * @param additionalRunArgs additional docker run arguments + */ + public void setAdditionalRunArgs(List additionalRunArgs) { + this.additionalRunArgs = new ArrayList<>(); + if (additionalRunArgs == null) { + return; + } + for (String additionalRunArg : additionalRunArgs) { + if (additionalRunArg != null && !additionalRunArg.isBlank()) { + this.additionalRunArgs.add(additionalRunArg); + } + } + } + + private String normalizeNetwork(String network) { + if (network == null) { + return null; + } + String trimmed = network.trim(); + return trimmed.isEmpty() ? null : trimmed; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxState.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxState.java new file mode 100644 index 000000000..c0d4aec47 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/impl/docker/DockerSandboxState.java @@ -0,0 +1,257 @@ +/* + * 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.sandbox.impl.docker; + +import io.agentscope.harness.agent.sandbox.SandboxState; +import java.util.ArrayList; +import java.util.List; + +/** + * Serializable state for a Docker-backed {@link io.agentscope.harness.agent.sandbox.Sandbox}. + * + *

    Persisted after each call to enable transparent container resume. If the container + * identified by {@link #containerId} is still alive on resume, the sandbox reconnects. If + * it is stopped, it is restarted. If it has been removed, a new container is created and the + * workspace is restored from snapshot. + */ +public class DockerSandboxState extends SandboxState { + + /** Docker container ID of the backing container. */ + private String containerId; + + /** Human-readable container name (e.g. {@code agentscope-sandbox-}). */ + private String containerName; + + /** Docker image used to create this container. */ + private String image; + + /** Workspace root path inside the container. */ + private String workspaceRoot; + + /** + * Whether the SDK owns the container lifecycle (create/stop/remove). + * When {@code false}, the container was injected by the developer and will not be removed. + */ + private boolean containerOwned = true; + + /** Optional memory limit in bytes stored for container recreation on resume. */ + private Long memorySizeBytes; + + /** Optional CPU count limit stored for container recreation on resume. */ + private Long cpuCount; + + /** Exposed port numbers stored for container recreation on resume. */ + private int[] exposedPorts = {}; + + /** Docker network mode or network name passed to {@code docker run --network}. */ + private String network; + + /** Additional raw arguments appended to {@code docker run} before the image name. */ + private List additionalRunArgs = new ArrayList<>(); + + /** + * Returns the Docker container ID. + * + * @return container ID, or {@code null} if the container has not been created yet + */ + public String getContainerId() { + return containerId; + } + + /** + * Sets the Docker container ID. + * + * @param containerId Docker container ID + */ + public void setContainerId(String containerId) { + this.containerId = containerId; + } + + /** + * Returns the container name. + * + * @return container name + */ + public String getContainerName() { + return containerName; + } + + /** + * Sets the container name. + * + * @param containerName container name + */ + public void setContainerName(String containerName) { + this.containerName = containerName; + } + + /** + * Returns the Docker image used for this container. + * + * @return Docker image + */ + public String getImage() { + return image; + } + + /** + * Sets the Docker image. + * + * @param image Docker image + */ + public void setImage(String image) { + this.image = image; + } + + /** + * Returns the workspace root path inside the container. + * + * @return workspace root path + */ + public String getWorkspaceRoot() { + return workspaceRoot; + } + + /** + * Sets the workspace root path inside the container. + * + * @param workspaceRoot absolute path inside the container + */ + public void setWorkspaceRoot(String workspaceRoot) { + this.workspaceRoot = workspaceRoot; + } + + /** + * Returns whether the SDK owns the container lifecycle. + * + * @return {@code true} if the SDK manages container creation and removal + */ + public boolean isContainerOwned() { + return containerOwned; + } + + /** + * Sets whether the SDK owns the container lifecycle. + * + * @param containerOwned {@code true} if the SDK should stop and remove the container on shutdown + */ + public void setContainerOwned(boolean containerOwned) { + this.containerOwned = containerOwned; + } + + /** + * Returns the optional memory limit in bytes. + * + * @return memory limit or {@code null} + */ + public Long getMemorySizeBytes() { + return memorySizeBytes; + } + + /** + * Sets the memory limit in bytes. + * + * @param memorySizeBytes memory limit + */ + public void setMemorySizeBytes(Long memorySizeBytes) { + this.memorySizeBytes = memorySizeBytes; + } + + /** + * Returns the optional CPU count limit. + * + * @return CPU count or {@code null} + */ + public Long getCpuCount() { + return cpuCount; + } + + /** + * Sets the CPU count limit. + * + * @param cpuCount number of CPUs + */ + public void setCpuCount(Long cpuCount) { + this.cpuCount = cpuCount; + } + + /** + * Returns the exposed port numbers. + * + * @return port numbers array + */ + public int[] getExposedPorts() { + return exposedPorts; + } + + /** + * Sets the exposed port numbers. + * + * @param exposedPorts port numbers + */ + public void setExposedPorts(int[] exposedPorts) { + this.exposedPorts = exposedPorts != null ? exposedPorts : new int[0]; + } + + /** + * Returns the docker network mode or network name. + * + * @return docker network value, or {@code null} when unset + */ + public String getNetwork() { + return network; + } + + /** + * Sets the docker network mode or network name. + * + * @param network docker network value + */ + public void setNetwork(String network) { + if (network == null) { + this.network = null; + return; + } + String trimmed = network.trim(); + this.network = trimmed.isEmpty() ? null : trimmed; + } + + /** + * Returns additional raw arguments appended to {@code docker run}. + * + * @return additional docker run arguments + */ + public List getAdditionalRunArgs() { + return additionalRunArgs; + } + + /** + * Sets additional raw arguments appended to {@code docker run}. + * + * @param additionalRunArgs additional docker run arguments + */ + public void setAdditionalRunArgs(List additionalRunArgs) { + this.additionalRunArgs = new ArrayList<>(); + if (additionalRunArgs == null) { + return; + } + for (String additionalRunArg : additionalRunArgs) { + if (additionalRunArg != null && !additionalRunArg.isBlank()) { + this.additionalRunArgs.add(additionalRunArg); + } + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/DirEntry.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/DirEntry.java new file mode 100644 index 000000000..ac9251233 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/DirEntry.java @@ -0,0 +1,72 @@ +/* + * 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.sandbox.layout; + +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Layout entry that creates a directory, optionally with nested child entries. + * + *

    Child entries are a map of filename to {@link WorkspaceEntry} and may themselves be + * nested {@code DirEntry} instances, enabling recursive tree-like workspace structures. + */ +public class DirEntry extends WorkspaceEntry { + + private Map children = new LinkedHashMap<>(); + + /** Creates an empty directory entry. */ + public DirEntry() {} + + /** + * Creates a directory entry with the given children. + * + * @param children map of child name to child entry + */ + public DirEntry(Map children) { + this.children = new LinkedHashMap<>(children); + } + + /** + * Returns the child entries in this directory. + * + * @return mutable map of child name to child entry + */ + public Map getChildren() { + return children; + } + + /** + * Sets the child entries for this directory. + * + * @param children map of child name to child entry + */ + public void setChildren(Map children) { + this.children = children != null ? children : new LinkedHashMap<>(); + } + + /** + * Adds a child entry to this directory. + * + * @param name child filename + * @param entry child entry + * @return this instance for chaining + */ + public DirEntry child(String name, WorkspaceEntry entry) { + this.children.put(name, entry); + return this; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/FileEntry.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/FileEntry.java new file mode 100644 index 000000000..9fa050eab --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/FileEntry.java @@ -0,0 +1,84 @@ +/* + * 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.sandbox.layout; + +/** + * Layout entry that creates a file with inline text content. + */ +public class FileEntry extends WorkspaceEntry { + + private String content = ""; + private String encoding = "UTF-8"; + + /** Creates an empty file entry. */ + public FileEntry() {} + + /** + * Creates a file entry with the given content. + * + * @param content the file content as a string + */ + public FileEntry(String content) { + this.content = content; + } + + /** + * Creates a file entry with the given content and encoding. + * + * @param content the file content as a string + * @param encoding the character encoding to use when writing the file + */ + public FileEntry(String content, String encoding) { + this.content = content; + this.encoding = encoding; + } + + /** + * Returns the file content. + * + * @return file content string + */ + public String getContent() { + return content; + } + + /** + * Sets the file content. + * + * @param content the file content string + */ + public void setContent(String content) { + this.content = content; + } + + /** + * Returns the character encoding used to write this file. + * + * @return encoding name (e.g. "UTF-8") + */ + public String getEncoding() { + return encoding; + } + + /** + * Sets the character encoding used to write this file. + * + * @param encoding encoding name (e.g. "UTF-8") + */ + public void setEncoding(String encoding) { + this.encoding = encoding; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/GitRepoEntry.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/GitRepoEntry.java new file mode 100644 index 000000000..d02344208 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/GitRepoEntry.java @@ -0,0 +1,79 @@ +/* + * 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.sandbox.layout; + +/** + * Layout entry that clones a Git repository into the sandbox workspace. + * + *

    The repository is cloned from {@code url} at the given {@code ref} + * (branch, tag, or commit SHA). Full git-clone logic is a Phase 2 feature; + * this class serves as the type skeleton for JSON serialization. + */ +public class GitRepoEntry extends WorkspaceEntry { + + private String url; + private String ref = "HEAD"; + + /** Creates an empty git repo entry. */ + public GitRepoEntry() {} + + /** + * Creates a git repo entry with the given URL and ref. + * + * @param url the repository clone URL + * @param ref the branch, tag, or commit SHA to check out + */ + public GitRepoEntry(String url, String ref) { + this.url = url; + this.ref = ref; + } + + /** + * Returns the repository clone URL. + * + * @return clone URL + */ + public String getUrl() { + return url; + } + + /** + * Sets the repository clone URL. + * + * @param url clone URL + */ + public void setUrl(String url) { + this.url = url; + } + + /** + * Returns the Git ref (branch, tag, or commit SHA) to check out. + * + * @return git ref + */ + public String getRef() { + return ref; + } + + /** + * Sets the Git ref. + * + * @param ref branch, tag, or commit SHA + */ + public void setRef(String ref) { + this.ref = ref; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/LocalDirEntry.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/LocalDirEntry.java new file mode 100644 index 000000000..99a7fc65e --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/LocalDirEntry.java @@ -0,0 +1,58 @@ +/* + * 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.sandbox.layout; + +/** + * Layout entry that recursively copies a directory from the host filesystem into the sandbox + * workspace. + * + *

    The {@code sourcePath} is an absolute path to a directory on the host machine. All files + * within that directory are copied recursively to the destination path in the workspace. + */ +public class LocalDirEntry extends WorkspaceEntry { + + private String sourcePath; + + /** Creates an empty local directory entry. */ + public LocalDirEntry() {} + + /** + * Creates a local directory entry with the given host source path. + * + * @param sourcePath absolute path to a directory on the host filesystem + */ + public LocalDirEntry(String sourcePath) { + this.sourcePath = sourcePath; + } + + /** + * Returns the host-side source directory path. + * + * @return absolute host directory path as a string + */ + public String getSourcePath() { + return sourcePath; + } + + /** + * Sets the host-side source directory path. + * + * @param sourcePath absolute host directory path as a string + */ + public void setSourcePath(String sourcePath) { + this.sourcePath = sourcePath; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/LocalFileEntry.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/LocalFileEntry.java new file mode 100644 index 000000000..9c4b5f48b --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/LocalFileEntry.java @@ -0,0 +1,57 @@ +/* + * 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.sandbox.layout; + +/** + * Layout entry that copies a single file from the host filesystem into the sandbox workspace. + * + *

    The {@code sourcePath} is an absolute path on the host machine. It is stored as a string + * to ensure safe JSON serialization across platforms. + */ +public class LocalFileEntry extends WorkspaceEntry { + + private String sourcePath; + + /** Creates an empty local file entry. */ + public LocalFileEntry() {} + + /** + * Creates a local file entry with the given host source path. + * + * @param sourcePath absolute path on the host filesystem + */ + public LocalFileEntry(String sourcePath) { + this.sourcePath = sourcePath; + } + + /** + * Returns the host-side source path. + * + * @return absolute host path as a string + */ + public String getSourcePath() { + return sourcePath; + } + + /** + * Sets the host-side source path. + * + * @param sourcePath absolute host path as a string + */ + public void setSourcePath(String sourcePath) { + this.sourcePath = sourcePath; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/WorkspaceEntry.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/WorkspaceEntry.java new file mode 100644 index 000000000..de0ed4037 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/WorkspaceEntry.java @@ -0,0 +1,65 @@ +/* + * 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.sandbox.layout; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * Base class for entries in a {@link io.agentscope.harness.agent.sandbox.WorkspaceSpec}. + * + *

    Each entry describes a single file or directory that should be present in the sandbox + * workspace at startup. Entries can be materialized from inline content, local host files, or + * remote sources. + * + *

    When {@code ephemeral} is {@code true}, this entry is always re-applied on every sandbox + * start, even when a snapshot exists. Non-ephemeral entries are persisted inside the snapshot + * and restored with the workspace. + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = FileEntry.class, name = "file"), + @JsonSubTypes.Type(value = DirEntry.class, name = "dir"), + @JsonSubTypes.Type(value = LocalFileEntry.class, name = "local_file"), + @JsonSubTypes.Type(value = LocalDirEntry.class, name = "local_dir"), + @JsonSubTypes.Type(value = GitRepoEntry.class, name = "git_repo"), + @JsonSubTypes.Type(value = WorkspaceProjectionEntry.class, name = "workspace_projection"), +}) +public abstract class WorkspaceEntry { + + private boolean ephemeral = false; + + /** + * Returns whether this entry is ephemeral. + * + *

    Ephemeral entries are always re-applied on session resume regardless of whether a + * snapshot exists. They are suitable for dynamic configuration that should not be snapshotted. + * + * @return true if this entry is ephemeral + */ + public boolean isEphemeral() { + return ephemeral; + } + + /** + * Sets whether this entry is ephemeral. + * + * @param ephemeral true to mark as ephemeral + */ + public void setEphemeral(boolean ephemeral) { + this.ephemeral = ephemeral; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/WorkspaceProjectionEntry.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/WorkspaceProjectionEntry.java new file mode 100644 index 000000000..a591872d7 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/layout/WorkspaceProjectionEntry.java @@ -0,0 +1,57 @@ +/* + * 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.sandbox.layout; + +import java.util.ArrayList; +import java.util.List; + +/** + * Layout entry that projects selected files/directories from host workspace into sandbox + * workspace when the {@link io.agentscope.harness.agent.sandbox.Sandbox} starts. + * + *

    This entry is sandbox-specific and is ignored by {@link + * io.agentscope.harness.agent.sandbox.WorkspaceSpecApplier}'s regular file materialization + * logic. Instead, projection is applied inside {@link + * io.agentscope.harness.agent.sandbox.Sandbox#start()} via archive hydration. + */ +public class WorkspaceProjectionEntry extends WorkspaceEntry { + + private String sourceRoot; + private List includeRoots = new ArrayList<>(); + + /** + * Absolute host-side workspace root used as source for projection. + */ + public String getSourceRoot() { + return sourceRoot; + } + + public void setSourceRoot(String sourceRoot) { + this.sourceRoot = sourceRoot; + } + + /** + * Relative include roots under {@link #getSourceRoot()}. + */ + public List getIncludeRoots() { + return includeRoots; + } + + public void setIncludeRoots(List includeRoots) { + this.includeRoots = + includeRoots != null ? new ArrayList<>(includeRoots) : new ArrayList<>(); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/LocalSandboxSnapshot.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/LocalSandboxSnapshot.java new file mode 100644 index 000000000..b397a9ac3 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/LocalSandboxSnapshot.java @@ -0,0 +1,148 @@ +/* + * 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.sandbox.snapshot; + +import io.agentscope.harness.agent.sandbox.SandboxErrorCode; +import io.agentscope.harness.agent.sandbox.SandboxException; +import java.io.InputStream; +import java.io.OutputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.util.UUID; + +/** + * Snapshot that persists workspace archives as tar files on the local filesystem. + * + *

    Archives are written atomically: the data is first written to a temporary file + * (prefixed with {@code .}) in the same directory, then moved to the final path using + * {@link StandardCopyOption#ATOMIC_MOVE}. This ensures the snapshot is either fully + * written or not present — never partially written. + * + *

    Security: {@code id} must be a single path segment with no {@code /} or {@code ..} + * characters to prevent path traversal attacks. + */ +public class LocalSandboxSnapshot implements SandboxSnapshot { + + private final String basePath; + private final String id; + + /** + * Creates a local snapshot. + * + * @param basePath directory where snapshot tar files are stored + * @param id unique identifier for this snapshot (must be a safe single path segment) + * @throws IllegalArgumentException if {@code id} contains unsafe characters + */ + public LocalSandboxSnapshot(String basePath, String id) { + validateId(id); + this.basePath = basePath; + this.id = id; + } + + /** + * {@inheritDoc} + * + *

    Writes the archive atomically to {@code {basePath}/{id}.tar}. + */ + @Override + public void persist(InputStream workspaceArchive) throws Exception { + Path targetPath = Path.of(basePath).resolve(id + ".tar"); + Path tmpPath = targetPath.resolveSibling("." + id + "." + UUID.randomUUID() + ".tmp"); + + try { + Files.createDirectories(targetPath.getParent()); + try (OutputStream out = + Files.newOutputStream( + tmpPath, + StandardOpenOption.CREATE, + StandardOpenOption.TRUNCATE_EXISTING)) { + workspaceArchive.transferTo(out); + } + Files.move( + tmpPath, + targetPath, + StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + } catch (Exception e) { + try { + Files.deleteIfExists(tmpPath); + } catch (Exception ignored) { + // Best-effort cleanup of the temp file + } + throw new SandboxException.SnapshotException(id, "Failed to persist snapshot", e); + } + } + + /** + * {@inheritDoc} + * + *

    Opens the snapshot tar file at {@code {basePath}/{id}.tar} for reading. + */ + @Override + public InputStream restore() throws Exception { + Path path = Path.of(basePath).resolve(id + ".tar"); + if (!Files.exists(path)) { + throw new SandboxException.SnapshotException(id); + } + try { + return Files.newInputStream(path); + } catch (Exception e) { + throw new SandboxException.SnapshotException(id, "Failed to read snapshot", e); + } + } + + /** + * {@inheritDoc} + * + * @return {@code true} if the snapshot tar file exists + */ + @Override + public boolean isRestorable() { + return Files.exists(Path.of(basePath).resolve(id + ".tar")); + } + + @Override + public String getId() { + return id; + } + + @Override + public String getType() { + return "local"; + } + + /** + * Returns the base directory where snapshot tar files are stored. + * + * @return base path string + */ + public String getBasePath() { + return basePath; + } + + private static void validateId(String id) { + if (id == null || id.isBlank()) { + throw new IllegalArgumentException("Snapshot id must not be null or blank"); + } + if (id.contains("/") || id.contains("\\") || id.contains("..") || id.contains("\0")) { + throw new SandboxException( + SandboxErrorCode.INVALID_MANIFEST_PATH, + "Snapshot id contains unsafe characters: " + id); + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/LocalSnapshotSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/LocalSnapshotSpec.java new file mode 100644 index 000000000..84c13ccb1 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/LocalSnapshotSpec.java @@ -0,0 +1,65 @@ +/* + * 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.sandbox.snapshot; + +import java.nio.file.Path; + +/** + * Snapshot spec that creates {@link LocalSandboxSnapshot} instances stored in a local directory. + * + *

    Each session gets its own snapshot file at {@code {basePath}/{sessionId}.tar}. + */ +public class LocalSnapshotSpec implements SandboxSnapshotSpec { + + private final String basePath; + + /** + * Creates a local snapshot spec. + * + * @param basePath directory where snapshot tar files will be stored + */ + public LocalSnapshotSpec(Path basePath) { + this.basePath = basePath.toString(); + } + + /** + * Creates a local snapshot spec. + * + * @param basePath directory path string where snapshot tar files will be stored + */ + public LocalSnapshotSpec(String basePath) { + this.basePath = basePath; + } + + /** + * {@inheritDoc} + * + * @return a new {@link LocalSandboxSnapshot} storing at {@code {basePath}/{snapshotId}.tar} + */ + @Override + public SandboxSnapshot build(String snapshotId) { + return new LocalSandboxSnapshot(basePath, snapshotId); + } + + /** + * Returns the base directory used for snapshot files. + * + * @return base path string + */ + public String getBasePath() { + return basePath; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/NoopSandboxSnapshot.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/NoopSandboxSnapshot.java new file mode 100644 index 000000000..1a636225d --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/NoopSandboxSnapshot.java @@ -0,0 +1,89 @@ +/* + * 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.sandbox.snapshot; + +import java.io.InputStream; +import java.io.OutputStream; + +/** + * No-op snapshot that does not persist anything. + * + *

    When using {@code NoopSandboxSnapshot}, workspace state is NOT preserved between + * session stops. Each time a session is started fresh, the full manifest is applied + * (Branch D of the start logic). Use this when workspace durability is not required. + */ +public class NoopSandboxSnapshot implements SandboxSnapshot { + + private static final String ID = "noop"; + + /** Creates a noop snapshot. */ + public NoopSandboxSnapshot() {} + + /** + * {@inheritDoc} + * + *

    Returns {@code false} — workspace archiving is skipped entirely when this + * snapshot is in use, so this method is never called in normal operation. + */ + @Override + public boolean isPersistenceEnabled() { + return false; + } + + /** + * {@inheritDoc} + * + *

    This implementation discards the archive stream entirely. + */ + @Override + public void persist(InputStream workspaceArchive) throws Exception { + // Intentionally discard — no-op snapshot does not persist anything + if (workspaceArchive != null) { + workspaceArchive.transferTo(OutputStream.nullOutputStream()); + } + } + + /** + * {@inheritDoc} + * + *

    Always throws {@link io.agentscope.harness.agent.sandbox.SandboxException.SnapshotException} + * since noop snapshots are never restorable. + */ + @Override + public InputStream restore() throws Exception { + throw new io.agentscope.harness.agent.sandbox.SandboxException.SnapshotException(ID); + } + + /** + * {@inheritDoc} + * + * @return always {@code false} + */ + @Override + public boolean isRestorable() { + return false; + } + + @Override + public String getId() { + return ID; + } + + @Override + public String getType() { + return "noop"; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/NoopSnapshotSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/NoopSnapshotSpec.java new file mode 100644 index 000000000..1f315267f --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/NoopSnapshotSpec.java @@ -0,0 +1,38 @@ +/* + * 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.sandbox.snapshot; + +/** + * Snapshot spec that creates {@link NoopSandboxSnapshot} instances. + * + *

    Using this spec means workspace state is never persisted between session stops. + * Every session start begins with a fresh workspace initialized from the full manifest. + */ +public class NoopSnapshotSpec implements SandboxSnapshotSpec { + + /** Creates a noop snapshot spec. */ + public NoopSnapshotSpec() {} + + /** + * {@inheritDoc} + * + * @return a new {@link NoopSandboxSnapshot} (ignores {@code snapshotId}) + */ + @Override + public SandboxSnapshot build(String snapshotId) { + return new NoopSandboxSnapshot(); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/OssRemoteSnapshotClient.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/OssRemoteSnapshotClient.java new file mode 100644 index 000000000..fbc393ab0 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/OssRemoteSnapshotClient.java @@ -0,0 +1,89 @@ +/* + * 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.sandbox.snapshot; + +import com.aliyun.oss.OSS; +import com.aliyun.oss.model.OSSObject; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.util.Objects; + +/** + * {@link RemoteSnapshotClient} backed by Alibaba Cloud OSS. + */ +public class OssRemoteSnapshotClient implements RemoteSnapshotClient { + + private final OSS ossClient; + private final String bucketName; + private final String keyPrefix; + + /** + * Creates an OSS-backed snapshot client. + * + * @param ossClient initialized OSS client + * @param bucketName bucket for snapshot objects + * @param keyPrefix object key prefix (optional, may be null/blank) + */ + public OssRemoteSnapshotClient(OSS ossClient, String bucketName, String keyPrefix) { + this.ossClient = Objects.requireNonNull(ossClient, "ossClient must not be null"); + if (bucketName == null || bucketName.isBlank()) { + throw new IllegalArgumentException("bucketName must not be blank"); + } + this.bucketName = bucketName; + this.keyPrefix = normalizePrefix(keyPrefix); + } + + @Override + public void upload(String snapshotId, InputStream data) throws Exception { + ossClient.putObject(bucketName, objectKey(snapshotId), data); + } + + @Override + public InputStream download(String snapshotId) throws Exception { + String key = objectKey(snapshotId); + if (!ossClient.doesObjectExist(bucketName, key)) { + throw new FileNotFoundException("Snapshot not found in OSS: " + key); + } + OSSObject object = ossClient.getObject(bucketName, key); + return object.getObjectContent(); + } + + @Override + public boolean exists(String snapshotId) throws Exception { + return ossClient.doesObjectExist(bucketName, objectKey(snapshotId)); + } + + private String objectKey(String snapshotId) { + if (snapshotId == null || snapshotId.isBlank()) { + throw new IllegalArgumentException("snapshotId must not be blank"); + } + return keyPrefix + snapshotId + ".tar"; + } + + private static String normalizePrefix(String prefix) { + if (prefix == null || prefix.isBlank()) { + return ""; + } + String p = prefix.replace('\\', '/'); + while (p.startsWith("/")) { + p = p.substring(1); + } + if (!p.isEmpty() && !p.endsWith("/")) { + p = p + "/"; + } + return p; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/OssSnapshotSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/OssSnapshotSpec.java new file mode 100644 index 000000000..b1bf1421b --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/OssSnapshotSpec.java @@ -0,0 +1,57 @@ +/* + * 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.sandbox.snapshot; + +import com.aliyun.oss.OSS; +import com.aliyun.oss.OSSClientBuilder; + +/** + * Convenience {@link SandboxSnapshotSpec} for Alibaba Cloud OSS snapshot storage. + */ +public class OssSnapshotSpec extends RemoteSnapshotSpec { + + /** + * Creates an OSS snapshot spec from an existing OSS client. + * + * @param ossClient initialized OSS client + * @param bucketName target bucket + * @param keyPrefix key prefix (optional, may be null/blank) + */ + public OssSnapshotSpec(OSS ossClient, String bucketName, String keyPrefix) { + super(new OssRemoteSnapshotClient(ossClient, bucketName, keyPrefix)); + } + + /** + * Creates an OSS snapshot spec from endpoint/credential settings. + * + * @param endpoint OSS endpoint (e.g. oss-cn-hangzhou.aliyuncs.com) + * @param accessKeyId access key id + * @param accessKeySecret access key secret + * @param bucketName target bucket + * @param keyPrefix key prefix (optional, may be null/blank) + */ + public OssSnapshotSpec( + String endpoint, + String accessKeyId, + String accessKeySecret, + String bucketName, + String keyPrefix) { + this( + new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret), + bucketName, + keyPrefix); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/RedisRemoteSnapshotClient.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/RedisRemoteSnapshotClient.java new file mode 100644 index 000000000..4ee41775e --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/RedisRemoteSnapshotClient.java @@ -0,0 +1,89 @@ +/* + * 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.sandbox.snapshot; + +import java.io.ByteArrayInputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Objects; +import redis.clients.jedis.UnifiedJedis; + +/** + * {@link RemoteSnapshotClient} backed by Redis binary values. + */ +public class RedisRemoteSnapshotClient implements RemoteSnapshotClient { + + private final UnifiedJedis jedis; + private final String keyPrefix; + private final Integer ttlSeconds; + + /** + * Creates a Redis-backed snapshot client. + * + * @param jedis initialized jedis client + * @param keyPrefix redis key prefix (optional) + * @param ttlSeconds optional TTL in seconds (null or negative means no TTL) + */ + public RedisRemoteSnapshotClient(UnifiedJedis jedis, String keyPrefix, Integer ttlSeconds) { + this.jedis = Objects.requireNonNull(jedis, "jedis must not be null"); + this.keyPrefix = normalizePrefix(keyPrefix); + this.ttlSeconds = ttlSeconds != null && ttlSeconds > 0 ? ttlSeconds : null; + } + + @Override + public void upload(String snapshotId, InputStream data) throws Exception { + byte[] key = redisKey(snapshotId); + byte[] value = data.readAllBytes(); + jedis.set(key, value); + if (ttlSeconds != null) { + jedis.expire(key, ttlSeconds); + } + } + + @Override + public InputStream download(String snapshotId) throws Exception { + byte[] data = jedis.get(redisKey(snapshotId)); + if (data == null) { + throw new FileNotFoundException( + "Snapshot not found in Redis: " + composeKey(snapshotId)); + } + return new ByteArrayInputStream(data); + } + + @Override + public boolean exists(String snapshotId) throws Exception { + return jedis.exists(redisKey(snapshotId)); + } + + private byte[] redisKey(String snapshotId) { + return composeKey(snapshotId).getBytes(StandardCharsets.UTF_8); + } + + private String composeKey(String snapshotId) { + if (snapshotId == null || snapshotId.isBlank()) { + throw new IllegalArgumentException("snapshotId must not be blank"); + } + return keyPrefix + snapshotId + ".tar"; + } + + private static String normalizePrefix(String prefix) { + if (prefix == null || prefix.isBlank()) { + return "agentscope:sandbox:snapshots:"; + } + return prefix.endsWith(":") ? prefix : prefix + ":"; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/RedisSnapshotSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/RedisSnapshotSpec.java new file mode 100644 index 000000000..a8fcd4f0b --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/RedisSnapshotSpec.java @@ -0,0 +1,35 @@ +/* + * 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.sandbox.snapshot; + +import redis.clients.jedis.UnifiedJedis; + +/** + * Convenience {@link SandboxSnapshotSpec} for Redis-backed snapshot storage. + */ +public class RedisSnapshotSpec extends RemoteSnapshotSpec { + + /** + * Creates a Redis snapshot spec. + * + * @param jedis initialized jedis client + * @param keyPrefix redis key prefix (optional) + * @param ttlSeconds optional TTL in seconds (null or negative means no TTL) + */ + public RedisSnapshotSpec(UnifiedJedis jedis, String keyPrefix, Integer ttlSeconds) { + super(new RedisRemoteSnapshotClient(jedis, keyPrefix, ttlSeconds)); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/RemoteSandboxSnapshot.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/RemoteSandboxSnapshot.java new file mode 100644 index 000000000..bace902fc --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/RemoteSandboxSnapshot.java @@ -0,0 +1,98 @@ +/* + * 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.sandbox.snapshot; + +import io.agentscope.harness.agent.sandbox.SandboxException; +import java.io.InputStream; + +/** + * Snapshot backed by a {@link RemoteSnapshotClient} (e.g. S3, OSS, GCS). + * + *

    This class delegates all operations to the provided client. The client is responsible + * for authentication, retry logic, and network error handling. + * + *

    Note: {@code RemoteSandboxSnapshot} is not directly serializable to JSON because + * {@link RemoteSnapshotClient} cannot be serialized. When persisting session state, + * only the {@code id} is needed — the client is re-injected from the builder at resume time. + */ +public class RemoteSandboxSnapshot implements SandboxSnapshot { + + private final RemoteSnapshotClient client; + private final String id; + + /** + * Creates a remote snapshot. + * + * @param client the remote storage client to delegate operations to + * @param id unique identifier for this snapshot + */ + public RemoteSandboxSnapshot(RemoteSnapshotClient client, String id) { + this.client = client; + this.id = id; + } + + /** + * {@inheritDoc} + * + *

    Uploads the archive via {@link RemoteSnapshotClient#upload}. + */ + @Override + public void persist(InputStream workspaceArchive) throws Exception { + try { + client.upload(id, workspaceArchive); + } catch (Exception e) { + throw new SandboxException.SnapshotException(id, "Remote upload failed", e); + } + } + + /** + * {@inheritDoc} + * + *

    Downloads the archive via {@link RemoteSnapshotClient#download}. + */ + @Override + public InputStream restore() throws Exception { + try { + return client.download(id); + } catch (Exception e) { + throw new SandboxException.SnapshotException(id, "Remote download failed", e); + } + } + + /** + * {@inheritDoc} + * + *

    Checks existence via {@link RemoteSnapshotClient#exists}. + */ + @Override + public boolean isRestorable() throws Exception { + try { + return client.exists(id); + } catch (Exception e) { + throw new SandboxException.SnapshotException(id, "Remote exists check failed", e); + } + } + + @Override + public String getId() { + return id; + } + + @Override + public String getType() { + return "remote"; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/RemoteSnapshotClient.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/RemoteSnapshotClient.java new file mode 100644 index 000000000..dfd8bd277 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/RemoteSnapshotClient.java @@ -0,0 +1,56 @@ +/* + * 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.sandbox.snapshot; + +import java.io.InputStream; + +/** + * User-implemented interface for uploading and downloading snapshot archives to/from remote + * storage (e.g. S3, OSS, GCS, or a custom blob store). + * + *

    Implement this interface and pass it to {@link RemoteSnapshotSpec} to enable + * remote snapshot storage. The implementation is responsible for authentication, + * retry logic, and connection management. + */ +public interface RemoteSnapshotClient { + + /** + * Uploads a snapshot archive to remote storage. + * + * @param snapshotId unique identifier for this snapshot + * @param data the workspace tar archive stream to upload + * @throws Exception if the upload fails + */ + void upload(String snapshotId, InputStream data) throws Exception; + + /** + * Downloads a snapshot archive from remote storage. + * + * @param snapshotId unique identifier for the snapshot to download + * @return an {@link InputStream} over the downloaded tar archive + * @throws Exception if the download fails or the snapshot does not exist + */ + InputStream download(String snapshotId) throws Exception; + + /** + * Checks whether a snapshot with the given ID exists in remote storage. + * + * @param snapshotId unique identifier to check + * @return {@code true} if the snapshot exists and can be downloaded + * @throws Exception if the check fails + */ + boolean exists(String snapshotId) throws Exception; +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/RemoteSnapshotSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/RemoteSnapshotSpec.java new file mode 100644 index 000000000..d89f3a483 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/RemoteSnapshotSpec.java @@ -0,0 +1,48 @@ +/* + * 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.sandbox.snapshot; + +/** + * Snapshot spec that creates {@link RemoteSandboxSnapshot} instances backed by a + * {@link RemoteSnapshotClient}. + * + *

    The same client instance is shared across all sessions created by this spec. + * Implement {@link RemoteSnapshotClient} to connect to your remote storage backend + * (e.g. AWS S3, Alibaba OSS, Google GCS). + */ +public class RemoteSnapshotSpec implements SandboxSnapshotSpec { + + private final RemoteSnapshotClient client; + + /** + * Creates a remote snapshot spec. + * + * @param client the remote storage client implementation to use + */ + public RemoteSnapshotSpec(RemoteSnapshotClient client) { + this.client = client; + } + + /** + * {@inheritDoc} + * + * @return a new {@link RemoteSandboxSnapshot} using this spec's client + */ + @Override + public SandboxSnapshot build(String snapshotId) { + return new RemoteSandboxSnapshot(client, snapshotId); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/SandboxSnapshot.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/SandboxSnapshot.java new file mode 100644 index 000000000..6f77437d9 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/SandboxSnapshot.java @@ -0,0 +1,88 @@ +/* + * 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.sandbox.snapshot; + +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import java.io.InputStream; + +/** + * Represents a persisted snapshot of a sandbox workspace. + * + *

    Snapshots allow workspace state to be preserved between session stops and restored on + * subsequent session starts. Implementations determine where and how the snapshot is stored: + * local disk ({@link LocalSandboxSnapshot}), remote storage ({@link RemoteSandboxSnapshot}), + * or not at all ({@link NoopSandboxSnapshot}). + */ +@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.PROPERTY, property = "type") +@JsonSubTypes({ + @JsonSubTypes.Type(value = NoopSandboxSnapshot.class, name = "noop"), + @JsonSubTypes.Type(value = LocalSandboxSnapshot.class, name = "local"), + @JsonSubTypes.Type(value = RemoteSandboxSnapshot.class, name = "remote"), +}) +public interface SandboxSnapshot { + + /** + * Persists the workspace archive to this snapshot. + * + * @param workspaceArchive tar stream of the workspace to persist; caller is responsible + * for closing the stream after this call + * @throws Exception if the persist operation fails + */ + void persist(InputStream workspaceArchive) throws Exception; + + /** + * Restores the workspace archive from this snapshot. + * + * @return tar stream of the workspace; caller is responsible for closing + * @throws Exception if the restore operation fails or the snapshot is not restorable + */ + InputStream restore() throws Exception; + + /** + * Returns whether this snapshot can currently be restored. + * + * @return true if {@link #restore()} would succeed + * @throws Exception if checking restorability fails + */ + boolean isRestorable() throws Exception; + + /** + * Returns the unique identifier for this snapshot. + * + * @return snapshot id + */ + String getId(); + + /** + * Returns the snapshot type discriminator used in JSON serialization. + * + * @return type string (e.g. "noop", "local", "remote") + */ + String getType(); + + /** + * Returns whether this snapshot actually persists data. + * + *

    When {@code false}, {@link io.agentscope.harness.agent.sandbox.AbstractBaseSandbox#stop()} skips the + * potentially expensive workspace archive step entirely. Defaults to {@code true}. + * + * @return false only for no-op implementations that discard all archive data + */ + default boolean isPersistenceEnabled() { + return true; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/SandboxSnapshotSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/SandboxSnapshotSpec.java new file mode 100644 index 000000000..939e3b448 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/snapshot/SandboxSnapshotSpec.java @@ -0,0 +1,37 @@ +/* + * 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.sandbox.snapshot; + +/** + * Factory that creates {@link SandboxSnapshot} instances for a given session ID. + * + *

    Implementations configure WHERE snapshots are stored: + * {@link NoopSnapshotSpec} (disabled), {@link LocalSnapshotSpec} (local disk), + * {@link RemoteSnapshotSpec} (remote storage). + * + *

    The {@code snapshotId} parameter passed to {@link #build} allows each session to have + * its own isolated snapshot file/object, while sharing the same storage configuration. + */ +public interface SandboxSnapshotSpec { + + /** + * Creates a {@link SandboxSnapshot} for the given session ID. + * + * @param snapshotId unique identifier for the snapshot (typically the session UUID) + * @return a new snapshot instance configured for the given ID + */ + SandboxSnapshot build(String snapshotId); +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/session/WorkspaceSession.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/session/WorkspaceSession.java new file mode 100644 index 000000000..e1cc9d1ce --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/session/WorkspaceSession.java @@ -0,0 +1,59 @@ +/* + * 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.session; + +import io.agentscope.core.session.JsonSession; +import java.nio.file.Path; + +/** + * Workspace-aware session that stores state under the agent's workspace directory. + * + *

    Storage layout: + * + *

    + * <workspace>/agents/<agentId>/context/<sessionId>/{key}.json
    + * <workspace>/agents/<agentId>/context/<sessionId>/{key}.jsonl
    + * 
    + * + *

    This extends {@link JsonSession} by computing the base directory as + * {@code /agents//context/}. The {@code sessionId} (carried by + * {@link io.agentscope.core.state.SessionKey#toIdentifier()}) is appended automatically + * by the parent class as a subdirectory, producing the full path above. + * + *

    This session is dedicated to ReActAgent runtime state persistence only (for example memory + * messages, agent metadata). Sandbox lifecycle state is stored separately through + * {@code SandboxStateStore}. + * + *

    Usage: + * + *

    {@code
    + * WorkspaceSession session = new WorkspaceSession(workspacePath, "my-agent");
    + * agent.saveTo(session, SimpleSessionKey.of("sess-001"));
    + * // Files written to: /agents/my-agent/context/sess-001/
    + * }
    + */ +public class WorkspaceSession extends JsonSession { + + /** + * Creates a workspace session for the given agent. + * + * @param workspace the workspace root directory (e.g. {@code .agentscope/workspace}) + * @param agentId the agent identifier used in the directory path + */ + public WorkspaceSession(Path workspace, String agentId) { + super(workspace.resolve("agents").resolve(agentId).resolve("context")); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/store/BaseStore.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/store/BaseStore.java new file mode 100644 index 000000000..e515e2fc4 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/store/BaseStore.java @@ -0,0 +1,64 @@ +/* + * 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.store; + +import java.util.List; +import java.util.Map; + +/** + * Abstract interface for a namespace-based key-value store. + * + *

    Items are organized by namespaces (hierarchical path-like tuples) + * and identified by a key within each namespace. + */ +public interface BaseStore { + + /** + * Get a single item by namespace and key. + * + * @param namespace hierarchical namespace path + * @param key the item key within the namespace + * @return the store item, or {@code null} if not found + */ + StoreItem get(List namespace, String key); + + /** + * Store or update an item. + * + * @param namespace hierarchical namespace path + * @param key the item key within the namespace + * @param value the data to store + */ + void put(List namespace, String key, Map value); + + /** + * Search for items within a namespace with pagination. + * + * @param namespace hierarchical namespace path + * @param limit maximum number of items to return + * @param offset number of items to skip + * @return list of matching store items + */ + List search(List namespace, int limit, int offset); + + /** + * Delete an item by namespace and key. + * + * @param namespace hierarchical namespace path + * @param key the item key to delete + */ + void delete(List namespace, String key); +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/store/InMemoryStore.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/store/InMemoryStore.java new file mode 100644 index 000000000..b4c497e25 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/store/InMemoryStore.java @@ -0,0 +1,87 @@ +/* + * 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.store; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Thread-safe in-memory implementation of {@link BaseStore}. + * + *

    Items are stored in a ConcurrentHashMap keyed by the concatenation of + * namespace components and the item key, separated by {@code '\0'}. + */ +public class InMemoryStore implements BaseStore { + + private final ConcurrentMap store = new ConcurrentHashMap<>(); + + @Override + public StoreItem get(List namespace, String key) { + return store.get(compoundKey(namespace, key)); + } + + @Override + public void put(List namespace, String key, Map value) { + store.put(compoundKey(namespace, key), new StoreItem(key, value)); + } + + @Override + public List search(List namespace, int limit, int offset) { + String prefix = namespacePrefix(namespace); + List matches = new ArrayList<>(); + for (Map.Entry entry : store.entrySet()) { + if (entry.getKey().startsWith(prefix)) { + matches.add(entry.getValue()); + } + } + Collections.sort(matches, (a, b) -> a.key().compareTo(b.key())); + + int start = Math.min(offset, matches.size()); + int end = Math.min(start + limit, matches.size()); + return matches.subList(start, end); + } + + @Override + public void delete(List namespace, String key) { + store.remove(compoundKey(namespace, key)); + } + + /** Returns the number of items currently stored. */ + public int size() { + return store.size(); + } + + /** Removes all items from the store. */ + public void clear() { + store.clear(); + } + + private static String compoundKey(List namespace, String key) { + return namespacePrefix(namespace) + key; + } + + private static String namespacePrefix(List namespace) { + StringBuilder sb = new StringBuilder(); + for (String component : namespace) { + sb.append(component).append('\0'); + } + return sb.toString(); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/store/NamespaceFactory.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/store/NamespaceFactory.java new file mode 100644 index 000000000..a29eb6792 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/store/NamespaceFactory.java @@ -0,0 +1,43 @@ +/* + * 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.store; + +import java.util.List; + +/** + * Factory that produces a namespace tuple for {@link BaseStore} operations at call time. + * + *

    Unlike a static namespace, a {@code NamespaceFactory} is invoked on every store + * operation (read, write, ls, etc.), allowing the namespace to vary based on runtime context such + * as the current session id, user id, or agent id. + * + *

    Example: + * + *

    {@code
    + * NamespaceFactory factory = () -> List.of("sessions", getCurrentSessionId(), "filesystem");
    + * RemoteFilesystem fs = new RemoteFilesystem(store, factory);
    + * }
    + */ +@FunctionalInterface +public interface NamespaceFactory { + + /** + * Returns the namespace tuple for the current operation context. + * + * @return non-null, non-empty list of namespace segments + */ + List getNamespace(); +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/store/StoreItem.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/store/StoreItem.java new file mode 100644 index 000000000..657f6b393 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/store/StoreItem.java @@ -0,0 +1,26 @@ +/* + * 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.store; + +import java.util.Map; + +/** + * A single item retrieved from a {@link BaseStore}. + * + * @param key the item's key within its namespace + * @param value the item's data as a string-keyed map + */ +public record StoreItem(String key, Map value) {} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/AgentSpecLoader.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/AgentSpecLoader.java new file mode 100644 index 000000000..608bfc094 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/AgentSpecLoader.java @@ -0,0 +1,170 @@ +/* + * 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.subagent; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Loads {@link SubagentSpec} definitions from Markdown files with YAML front matter. Compatible + * with Spring AI agent spec format. + * + *

    File format: + * + *

    + * ---
    + * name: Explore
    + * description: Fast agent for exploring codebases...
    + * tools: Read, Grep, Glob
    + * ---
    + *
    + * # System prompt (markdown body)
    + * You are a file search specialist...
    + * 
    + * + *

    Front matter fields: + * + *

      + *
    • {@code name} (required) — maps to {@link SubagentSpec#getName()} + *
    • {@code description} (required) + *
    • {@code tools} (optional, comma-separated) — maps to {@link SubagentSpec#getTools()} + *
    • {@code model} (optional) — override model name, maps to {@link SubagentSpec#getModel()} + *
    • {@code maxIters} (optional, default 10) + *
    + * + *

    The Markdown body becomes the system prompt. + */ +public final class AgentSpecLoader { + + private static final Logger log = LoggerFactory.getLogger(AgentSpecLoader.class); + private static final ObjectMapper YAML_MAPPER = new ObjectMapper(new YAMLFactory()); + + private AgentSpecLoader() {} + + /** Recursively scans a directory for {@code .md} files and parses each into a SubagentSpec. */ + public static List loadFromDirectory(Path rootPath) { + if (rootPath == null || !Files.isDirectory(rootPath)) { + return Collections.emptyList(); + } + List specs = new ArrayList<>(); + try (Stream paths = Files.walk(rootPath)) { + paths.filter(Files::isRegularFile) + .filter(p -> p.getFileName().toString().endsWith(".md")) + .forEach( + path -> { + try { + SubagentSpec spec = loadFromFile(path); + if (spec != null) { + specs.add(spec); + log.debug( + "Loaded agent spec '{}' from {}", + spec.getName(), + path); + } + } catch (Exception e) { + log.warn( + "Failed to load agent spec from {}: {}", + path, + e.getMessage()); + } + }); + } catch (IOException e) { + log.warn("Failed to walk directory {}: {}", rootPath, e.getMessage()); + } + return specs; + } + + public static SubagentSpec loadFromFile(Path filePath) throws IOException { + String content = Files.readString(filePath, StandardCharsets.UTF_8); + return parse(content); + } + + /** + * Parses markdown content with YAML front matter into a {@link SubagentSpec}. + * + * @return parsed spec, or null if the content is malformed + */ + @SuppressWarnings("unchecked") + public static SubagentSpec parse(String markdown) { + if (markdown == null || markdown.isBlank() || !markdown.startsWith("---")) { + return null; + } + int endIdx = markdown.indexOf("---", 3); + if (endIdx == -1) { + log.warn("Agent spec front matter not closed with ---"); + return null; + } + + String frontMatterStr = markdown.substring(3, endIdx).trim(); + String body = markdown.substring(endIdx + 3).trim(); + + Map frontMatter; + try { + frontMatter = YAML_MAPPER.readValue(frontMatterStr, Map.class); + } catch (Exception e) { + log.warn("Failed to parse YAML front matter: {}", e.getMessage()); + return null; + } + if (frontMatter == null || frontMatter.isEmpty()) { + return null; + } + + String name = asString(frontMatter.get("name")); + String description = asString(frontMatter.get("description")); + if (name == null || name.isBlank()) { + log.warn("Agent spec missing required 'name' in front matter"); + return null; + } + if (description == null || description.isBlank()) { + log.warn("Agent spec missing required 'description' in front matter"); + return null; + } + + SubagentSpec spec = new SubagentSpec(name, description); + spec.setSysPrompt(body.isEmpty() ? null : body); + spec.setTools(parseToolNames(asString(frontMatter.get("tools")))); + spec.setModel(asString(frontMatter.get("model"))); + + Object maxItersObj = frontMatter.get("maxIters"); + if (maxItersObj instanceof Number n) { + spec.setMaxIters(n.intValue()); + } + + return spec; + } + + private static String asString(Object v) { + return v != null ? v.toString().trim() : null; + } + + private static List parseToolNames(String toolsStr) { + if (toolsStr == null || toolsStr.isBlank()) { + return List.of(); + } + return Stream.of(toolsStr.split(",")).map(String::trim).filter(s -> !s.isEmpty()).toList(); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/DefaultAgentManager.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/DefaultAgentManager.java new file mode 100644 index 000000000..50c6e32ca --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/DefaultAgentManager.java @@ -0,0 +1,91 @@ +/* + * 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.subagent; + +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.harness.agent.HarnessAgent; +import io.agentscope.harness.agent.tool.AgentSpawnTool; +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.util.List; +import java.util.Map; +import reactor.core.publisher.Mono; + +/** + * Pure agent factory and invoker — knows how to create agents from registered factories and invoke + * them with a prompt. + * + *

    This is the agent-internal layer. It has no session registry, no lane + * management, no run tracking. The + * agent-internal {@link AgentSpawnTool} uses this directly for + * lightweight subagent invocation. + */ +public final class DefaultAgentManager { + + private final Map agentFactories; + private final WorkspaceManager workspaceManager; + + public DefaultAgentManager( + Map agentFactories, WorkspaceManager workspaceManager) { + this.agentFactories = Map.copyOf(agentFactories); + this.workspaceManager = workspaceManager; + } + + /** Whether a factory is registered for the given agent id. */ + public boolean hasAgent(String agentId) { + return agentId != null && agentFactories.containsKey(agentId); + } + + /** Immutable view of registered subagent factories keyed by {@code agent_id}. */ + public Map getAgentFactories() { + return agentFactories; + } + + /** + * Creates a new agent instance from the registered factory. + * + * @throws IllegalArgumentException if no factory is registered for the given id + */ + public Agent createAgent(String agentId) { + SubagentFactory factory = agentFactories.get(agentId); + if (factory == null) { + throw new IllegalArgumentException("Unknown agent_id: " + agentId); + } + return factory.create(); + } + + /** + * Invokes an agent with a user prompt. Handles both plain {@link Agent} and {@link + * HarnessAgent} (injects {@link RuntimeContext} for the latter). + */ + public Mono invokeAgent(Agent agent, String sessionId, String prompt) { + if (agent instanceof HarnessAgent harness) { + RuntimeContext ctx = RuntimeContext.builder().sessionId(sessionId).build(); + return harness.call(userMessage(prompt), ctx); + } + return agent.call(List.of(userMessage(prompt))); + } + + public WorkspaceManager getWorkspaceManager() { + return workspaceManager; + } + + private static Msg userMessage(String prompt) { + return Msg.builder().role(MsgRole.USER).textContent(prompt).build(); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentFactory.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentFactory.java new file mode 100644 index 000000000..9cf6f294e --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentFactory.java @@ -0,0 +1,33 @@ +/* + * 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.subagent; + +import io.agentscope.core.agent.Agent; + +/** + * Creates a new subagent instance for a single spawn or session. Registered under an {@code + * agent_id} in {@link DefaultAgentManager}; each {@link #create()} call should return a fresh + * agent when isolation is required. + * + *

    This type replaces a raw {@link java.util.function.Supplier} for subagent wiring so call sites + * and maps are self-documenting. + */ +@FunctionalInterface +public interface SubagentFactory { + + /** Builds a new subagent instance (typically a new {@link io.agentscope.harness.agent.HarnessAgent}). */ + Agent create(); +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentSpec.java new file mode 100644 index 000000000..aa1966b93 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentSpec.java @@ -0,0 +1,124 @@ +/* + * 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.subagent; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import java.util.List; + +/** + * Specification for a sub-agent loaded from configuration (subagent.yml). + * + *

    Example YAML: + * + *

    + * subagents:
    + *   - name: content-reviewer
    + *     description: Use this agent after creating significant content
    + *     sysPrompt: You are an expert content reviewer...
    + *     tools: []
    + *   - name: research-analyst
    + *     description: Use this agent for deep research tasks
    + *     sysPrompt: You are a research analyst...
    + * 
    + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public class SubagentSpec { + + @JsonProperty("name") + private String name; + + @JsonProperty("description") + private String description; + + @JsonProperty("sysPrompt") + private String sysPrompt; + + @JsonProperty("tools") + private List tools; + + @JsonProperty("workspace") + private String workspace; + + @JsonProperty("model") + private String model; + + @JsonProperty("maxIters") + private int maxIters = 10; + + public SubagentSpec() {} + + public SubagentSpec(String name, String description) { + this.name = name; + this.description = description; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDescription() { + return description; + } + + public void setDescription(String description) { + this.description = description; + } + + public String getSysPrompt() { + return sysPrompt; + } + + public void setSysPrompt(String sysPrompt) { + this.sysPrompt = sysPrompt; + } + + public List getTools() { + return tools; + } + + public void setTools(List tools) { + this.tools = tools; + } + + public String getModel() { + return model; + } + + public void setModel(String model) { + this.model = model; + } + + public String getWorkspace() { + return workspace; + } + + public void setWorkspace(String workspace) { + this.workspace = workspace; + } + + public int getMaxIters() { + return maxIters; + } + + public void setMaxIters(int maxIters) { + this.maxIters = maxIters; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/BackgroundTask.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/BackgroundTask.java new file mode 100644 index 000000000..fff311288 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/BackgroundTask.java @@ -0,0 +1,152 @@ +/* + * 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.subagent.task; + +import java.time.Instant; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +/** + * Wraps a {@link CompletableFuture} to track background subagent task execution with status, + * result, error, and lifecycle metadata. Thread-safe by delegation to the underlying future. + * + *

    Lifecycle metadata: + * + *

      + *
    • {@code agentId} — which subagent type is executing + *
    • {@code createdAt} — when the task was created + *
    • {@code lastCheckedAt} — when the task status was last polled + *
    + */ +public class BackgroundTask { + + private final String taskId; + private final String agentId; + private final CompletableFuture future; + private final Instant createdAt; + private volatile Instant lastCheckedAt; + private volatile boolean cancelled; + + public BackgroundTask(String taskId, String agentId, CompletableFuture future) { + this.taskId = taskId; + this.agentId = agentId; + this.future = future; + this.createdAt = Instant.now(); + this.lastCheckedAt = this.createdAt; + } + + public String getTaskId() { + return taskId; + } + + public String getAgentId() { + return agentId; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getLastCheckedAt() { + return lastCheckedAt; + } + + public void updateLastCheckedAt() { + this.lastCheckedAt = Instant.now(); + } + + public boolean isCompleted() { + return future.isDone(); + } + + /** + * Returns the structured {@link TaskStatus} for this task, reflecting the underlying future + * state and any explicit cancellation. + */ + public TaskStatus getTaskStatus() { + if (cancelled || future.isCancelled()) { + return TaskStatus.CANCELLED; + } + if (future.isCompletedExceptionally()) { + return TaskStatus.FAILED; + } + if (future.isDone()) { + return TaskStatus.COMPLETED; + } + return TaskStatus.RUNNING; + } + + /** Returns a human-readable status string. */ + public String getStatus() { + TaskStatus ts = getTaskStatus(); + if (ts == TaskStatus.FAILED) { + Exception error = getError(); + return "Failed: " + (error != null ? error.getMessage() : "Unknown error"); + } + return ts.name().charAt(0) + ts.name().substring(1).toLowerCase(); + } + + /** Non-blocking result peek; returns null if not yet completed or if an error occurred. */ + public String getResult() { + try { + return future.getNow(null); + } catch (Exception e) { + return null; + } + } + + public Exception getError() { + if (future.isCompletedExceptionally()) { + try { + future.getNow(null); + } catch (Exception e) { + return (e.getCause() instanceof Exception cause) ? cause : e; + } + } + return null; + } + + /** + * Blocks until the task completes or the timeout elapses. + * + * @return true if completed within timeout, false if timed out + */ + public boolean waitForCompletion(long timeoutMs) throws InterruptedException { + if (future.isDone()) { + return true; + } + try { + future.get(timeoutMs, TimeUnit.MILLISECONDS); + return true; + } catch (InterruptedException e) { + throw e; + } catch (TimeoutException e) { + return false; + } catch (Exception e) { + return true; + } + } + + /** + * Cancels the task. Sets the internal cancelled flag so that {@link #getTaskStatus()} returns + * {@link TaskStatus#CANCELLED} even if the future cannot be interrupted. + */ + public boolean cancel(boolean mayInterruptIfRunning) { + this.cancelled = true; + return future.cancel(mayInterruptIfRunning); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/DefaultTaskRepository.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/DefaultTaskRepository.java new file mode 100644 index 000000000..e8ac3dea0 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/DefaultTaskRepository.java @@ -0,0 +1,125 @@ +/* + * 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.subagent.task; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +/** + * Default in-memory {@link TaskRepository} backed by a cached daemon thread pool. Each submitted + * task runs asynchronously via {@link CompletableFuture#supplyAsync}. + */ +public class DefaultTaskRepository implements TaskRepository { + + private final Map tasks = new ConcurrentHashMap<>(); + private final ExecutorService executor; + private final boolean ownsExecutor; + + public DefaultTaskRepository() { + this( + Executors.newCachedThreadPool( + r -> { + Thread t = new Thread(r); + t.setDaemon(true); + t.setName("subagent-task-" + t.getId()); + return t; + }), + true); + } + + public DefaultTaskRepository(ExecutorService executor) { + this(executor, false); + } + + private DefaultTaskRepository(ExecutorService executor, boolean ownsExecutor) { + this.executor = executor; + this.ownsExecutor = ownsExecutor; + } + + @Override + public BackgroundTask getTask(String taskId) { + return tasks.get(taskId); + } + + @Override + public BackgroundTask putTask(String taskId, String agentId, Supplier taskExecution) { + CompletableFuture future = CompletableFuture.supplyAsync(taskExecution, executor); + BackgroundTask task = new BackgroundTask(taskId, agentId, future); + tasks.put(taskId, task); + return task; + } + + @Override + public void removeTask(String taskId) { + tasks.remove(taskId); + } + + @Override + public void clear() { + tasks.clear(); + } + + @Override + public Collection listTasks(TaskStatus filter) { + if (filter == null) { + return List.copyOf(tasks.values()); + } + List result = new ArrayList<>(); + for (BackgroundTask task : tasks.values()) { + if (task.getTaskStatus() == filter) { + result.add(task); + } + } + return result; + } + + @Override + public boolean cancelTask(String taskId) { + BackgroundTask task = tasks.get(taskId); + if (task == null) { + return false; + } + task.cancel(true); + return true; + } + + public void clearCompletedTasks() { + tasks.entrySet().removeIf(e -> e.getValue().isCompleted()); + } + + /** Shuts down the thread pool if this repository owns it. */ + public void shutdown() { + if (ownsExecutor && executor != null) { + executor.shutdown(); + try { + if (!executor.awaitTermination(60, TimeUnit.SECONDS)) { + executor.shutdownNow(); + } + } catch (InterruptedException e) { + executor.shutdownNow(); + Thread.currentThread().interrupt(); + } + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/TaskRepository.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/TaskRepository.java new file mode 100644 index 000000000..0fafc1e66 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/TaskRepository.java @@ -0,0 +1,57 @@ +/* + * 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.subagent.task; + +import java.util.Collection; +import java.util.function.Supplier; + +/** + * Repository for managing background subagent tasks. Supports async execution with retrieval by + * task ID, listing, and cancellation. + */ +public interface TaskRepository { + + /** Retrieve a background task by its ID, or null if not found. */ + BackgroundTask getTask(String taskId); + + /** + * Submit a new background task; the supplier runs asynchronously. + * + * @param taskId unique identifier for the task + * @param agentId the subagent type that is executing this task + * @param taskExecution the work to execute asynchronously + * @return the created background task + */ + BackgroundTask putTask(String taskId, String agentId, Supplier taskExecution); + + void removeTask(String taskId); + + void clear(); + + /** + * List all tracked tasks, optionally filtered by status. + * + * @param filter if non-null, only return tasks with this status; null returns all tasks + */ + Collection listTasks(TaskStatus filter); + + /** + * Cancel a running task by its ID. + * + * @return true if the task was found and cancellation was attempted + */ + boolean cancelTask(String taskId); +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/TaskStatus.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/TaskStatus.java new file mode 100644 index 000000000..bcfba16f3 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/TaskStatus.java @@ -0,0 +1,36 @@ +/* + * 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.subagent.task; + +/** + * Lifecycle status of a background subagent task. Terminal statuses ({@link #isTerminal()}) will + * never change, so status checks can be skipped for finished tasks. + */ +public enum TaskStatus { + PENDING, + RUNNING, + COMPLETED, + FAILED, + CANCELLED; + + /** + * Whether this status represents a final state that will never change. Useful for skipping + * redundant status polling on tasks that are already done. + */ + public boolean isTerminal() { + return this == COMPLETED || this == FAILED || this == CANCELLED; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java new file mode 100644 index 000000000..36a4c24b4 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/AgentSpawnTool.java @@ -0,0 +1,323 @@ +/* + * 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.tool; + +import io.agentscope.core.agent.Agent; +import io.agentscope.core.message.Msg; +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; +import io.agentscope.harness.agent.subagent.DefaultAgentManager; +import io.agentscope.harness.agent.subagent.task.TaskRepository; +import java.time.Duration; +import java.util.Objects; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Simple subagent tool for agent-internal use. Much lighter than {@code SessionsTool}: + * + *
      + *
    • {@code agent_spawn} — spawn a subagent, run task, return result (sync or async) + *
    • {@code agent_send} — send follow-up message to a previously spawned subagent + *
    • {@code agent_list} — list active subagents + *
    + * + *

    No sessions, no lanes, no run registry, no announce dispatch. Just "create agent, invoke, + * return result". Uses {@link DefaultAgentManager} for agent creation and invocation only. + */ +public class AgentSpawnTool { + + private static final Logger log = LoggerFactory.getLogger(AgentSpawnTool.class); + + private static final int DEFAULT_TIMEOUT_SECONDS = 30; + private static final int MAX_TIMEOUT_SECONDS = 600; + private static final int MAX_SPAWN_DEPTH = 3; + + private static final String BG_RESULT_TEMPLATE = + """ + status: accepted + task_id: %s + Use task_output(task_id='%s') to retrieve the result, \ + task_cancel(task_id='%s') to cancel, or task_list() to see all tasks.\ + """; + + private final DefaultAgentManager agentManager; + private final TaskRepository taskRepository; + private final int parentSpawnDepth; + + private record SpawnedAgent( + String key, String agentId, String sessionId, String label, Agent agent, int depth) {} + + private final ConcurrentHashMap agentsByKey = new ConcurrentHashMap<>(); + private final ConcurrentHashMap labelToKey = new ConcurrentHashMap<>(); + + public AgentSpawnTool( + DefaultAgentManager agentManager, TaskRepository taskRepository, int parentSpawnDepth) { + this.agentManager = Objects.requireNonNull(agentManager, "agentManager"); + this.taskRepository = taskRepository; + this.parentSpawnDepth = parentSpawnDepth; + } + + @Tool( + name = "agent_spawn", + description = + """ + Spawn an isolated subagent for delegated or background work. \ + Every response starts with three lines: agent_key (pass this verbatim to \ + agent_send as agent_key), agent_id (the subagent type name), and session_id \ + (internal; do not use as agent_key). Sync mode returns the reply below that; \ + async (timeout_seconds=0) adds task_id for task_output — task_id is NOT agent_key.\ + """) + public String agentSpawn( + @ToolParam(name = "agent_id", description = "Subagent identifier to instantiate") + String agentId, + @ToolParam( + name = "task", + description = "Task or prompt to send to the spawned agent", + required = false) + String task, + @ToolParam( + name = "label", + description = + "Optional human-readable label for referencing via agent_send", + required = false) + String label, + @ToolParam( + name = "timeout_seconds", + description = + """ + Max seconds to wait for the task result. 0=fire-and-forget, \ + returns task_id. Default: 30. Max: 600.\ + """, + required = false) + Integer timeoutSeconds) { + + int nextDepth = parentSpawnDepth + 1; + if (nextDepth > MAX_SPAWN_DEPTH) { + return "Error: Maximum spawn depth exceeded (max=" + MAX_SPAWN_DEPTH + ")"; + } + if (!agentManager.hasAgent(agentId)) { + return "Error: Unknown agent_id: " + agentId; + } + + String canonLabel = label != null && !label.isBlank() ? label.trim() : null; + if (canonLabel != null && labelToKey.containsKey(canonLabel.toLowerCase())) { + return "Error: Label already in use: " + canonLabel; + } + + Agent agent = agentManager.createAgent(agentId); + String key = "agent:" + agentId + ":" + UUID.randomUUID(); + String sessionId = "sub-" + UUID.randomUUID(); + + SpawnedAgent spawned = + new SpawnedAgent(key, agentId, sessionId, canonLabel, agent, nextDepth); + agentsByKey.put(key, spawned); + if (canonLabel != null) { + labelToKey.put(canonLabel.toLowerCase(), key); + } + + String spawnInfo = formatSpawnInfo(key, agentId, sessionId); + boolean hasTask = task != null && !task.isBlank(); + + if (!hasTask) { + return spawnInfo + "\nstatus: accepted"; + } + + long timeoutMs = resolveTimeoutMs(timeoutSeconds, DEFAULT_TIMEOUT_SECONDS); + + if (timeoutMs == 0) { + String taskId = "task_" + UUID.randomUUID(); + final String capturedTask = task; + taskRepository.putTask( + taskId, + agentId, + () -> { + try { + Msg reply = + agentManager + .invokeAgent(agent, sessionId, capturedTask) + .block(); + return reply != null ? reply.getTextContent() : ""; + } catch (RuntimeException e) { + return "Error: " + + (e.getMessage() != null + ? e.getMessage() + : e.getClass().getSimpleName()); + } + }); + return spawnInfo + "\n" + String.format(BG_RESULT_TEMPLATE, taskId, taskId, taskId); + } + + try { + Msg reply = + agentManager + .invokeAgent(agent, sessionId, task.trim()) + .block(Duration.ofMillis(timeoutMs)); + String text = reply != null ? reply.getTextContent() : ""; + return spawnInfo + "\nstatus: ok\nreply:\n" + text; + } catch (RuntimeException e) { + String err = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + log.warn("agent_spawn execute failed: agentId={}", agentId, e); + return spawnInfo + "\nstatus: error\nerror: " + err; + } + } + + @Tool( + name = "agent_send", + description = + """ + Send a message to an existing subagent. Use the exact string from the \ + agent_key line of agent_spawn output (starts with agent:), or the label \ + you set at spawn. Do not pass agent_id, session_id, or task_id here. \ + timeout_seconds=0 returns task_id for task_output.\ + """) + public String agentSend( + @ToolParam( + name = "agent_key", + description = + "Exact value from agent_spawn's first line after 'agent_key: '" + + " (format agent::). Not agent_id, session_id," + + " or task_id. Mutually exclusive with label.", + required = false) + String agentKey, + @ToolParam( + name = "label", + description = + "Agent label assigned at spawn time. Mutually exclusive with" + + " agent_key.", + required = false) + String label, + @ToolParam(name = "message", description = "Message to send to the subagent") + String message, + @ToolParam( + name = "timeout_seconds", + description = + """ + Max seconds to wait for a reply. 0=fire-and-forget, returns \ + task_id. Default: 30. Max: 600.\ + """, + required = false) + Integer timeoutSeconds) { + + boolean hasKey = agentKey != null && !agentKey.isBlank(); + boolean hasLabel = label != null && !label.isBlank(); + if (hasKey && hasLabel) { + return "Error: Provide either agent_key or label, not both."; + } + if (!hasKey && !hasLabel) { + return "Error: Either agent_key or label is required."; + } + if (message == null || message.isBlank()) { + return "Error: message is required"; + } + + String key; + if (hasKey) { + key = agentKey.trim(); + } else { + key = labelToKey.get(label.trim().toLowerCase()); + if (key == null) { + return "Error: Unknown label: " + label.trim(); + } + } + + SpawnedAgent spawned = agentsByKey.get(key); + if (spawned == null) { + return "Error: Unknown agent_key: " + key; + } + + long timeoutMs = resolveTimeoutMs(timeoutSeconds, DEFAULT_TIMEOUT_SECONDS); + + if (timeoutMs == 0) { + String taskId = "task_" + UUID.randomUUID(); + taskRepository.putTask( + taskId, + spawned.agentId(), + () -> { + try { + Msg reply = + agentManager + .invokeAgent( + spawned.agent(), spawned.sessionId(), message) + .block(); + return reply != null ? reply.getTextContent() : ""; + } catch (RuntimeException e) { + return "Error: " + + (e.getMessage() != null + ? e.getMessage() + : e.getClass().getSimpleName()); + } + }); + return String.format(BG_RESULT_TEMPLATE, taskId, taskId, taskId); + } + + try { + Msg reply = + agentManager + .invokeAgent(spawned.agent(), spawned.sessionId(), message.trim()) + .block(Duration.ofMillis(timeoutMs)); + String text = reply != null ? reply.getTextContent() : ""; + return "agent_key: " + key + "\nstatus: ok\nreply:\n" + text; + } catch (RuntimeException e) { + String err = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); + log.warn("agent_send failed: key={}", key, e); + return "Error: " + err; + } + } + + @Tool(name = "agent_list", description = "List active subagents spawned by this agent.") + public String agentList() { + if (agentsByKey.isEmpty()) { + return "No active subagents."; + } + + StringBuilder sb = + new StringBuilder("Active subagents (").append(agentsByKey.size()).append("):\n"); + for (SpawnedAgent a : agentsByKey.values()) { + sb.append("- agent_key: ").append(a.key()).append("\n"); + sb.append(" agent_id: ").append(a.agentId()).append("\n"); + if (a.label() != null) { + sb.append(" label: ").append(a.label()).append("\n"); + } + sb.append(" spawn_depth: ").append(a.depth()).append("\n"); + } + return sb.toString().trim(); + } + + // ----------------------------------------------------------------- + // Helpers + // ----------------------------------------------------------------- + + private static long resolveTimeoutMs(Integer timeoutSeconds, int defaultSeconds) { + if (timeoutSeconds == null) { + return (long) defaultSeconds * 1_000; + } + if (timeoutSeconds <= 0) { + return 0L; + } + return (long) Math.min(timeoutSeconds, MAX_TIMEOUT_SECONDS) * 1_000; + } + + private static String formatSpawnInfo(String key, String agentId, String sessionId) { + StringBuilder sb = new StringBuilder(); + sb.append("agent_key: ").append(key).append("\n"); + sb.append("agent_id: ").append(agentId).append("\n"); + sb.append("session_id: ").append(sessionId); + return sb.toString(); + } +} 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 new file mode 100644 index 000000000..f002fa2c3 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/FilesystemTool.java @@ -0,0 +1,158 @@ +/* + * 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.tool; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; +import io.agentscope.harness.agent.filesystem.AbstractFilesystem; +import io.agentscope.harness.agent.filesystem.model.EditResult; +import io.agentscope.harness.agent.filesystem.model.FileInfo; +import io.agentscope.harness.agent.filesystem.model.GlobResult; +import io.agentscope.harness.agent.filesystem.model.GrepMatch; +import io.agentscope.harness.agent.filesystem.model.GrepResult; +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 java.util.List; +import java.util.stream.Collectors; + +/** + * File system tools backed by a {@link AbstractFilesystem}, exposing read/write/edit/grep/glob + * operations as agent-callable tools. + */ +public class FilesystemTool { + + private final AbstractFilesystem abstractFilesystem; + + public FilesystemTool(AbstractFilesystem abstractFilesystem) { + this.abstractFilesystem = abstractFilesystem; + } + + @Tool( + name = "read_file", + description = + "Read file content with line numbers. Supports pagination via offset and" + + " limit.") + public String readFile( + RuntimeContext runtimeContext, + @ToolParam(name = "path", description = "File path to read") String path, + @ToolParam( + name = "offset", + description = "Start line (0-indexed). Default: 0 (from beginning)") + int offset, + @ToolParam(name = "limit", description = "Max lines to return. Default: 0 (all lines)") + int limit) { + ReadResult r = abstractFilesystem.read(runtimeContext, path, offset, limit); + if (!r.isSuccess()) { + return "Error: " + r.error(); + } + return r.fileData() != null ? r.fileData().content() : ""; + } + + @Tool( + name = "write_file", + description = "Write content to a new file, creating parent directories if needed.") + 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); + return r.isSuccess() ? "Written to " + r.path() : "Error: " + r.error(); + } + + @Tool( + name = "edit_file", + description = + "Perform exact string replacement in a file. The old_string must be unique" + + " unless replace_all is true.") + public String editFile( + RuntimeContext runtimeContext, + @ToolParam(name = "path", description = "File to edit") String path, + @ToolParam(name = "old_string", description = "Text to find") String oldString, + @ToolParam(name = "new_string", description = "Replacement text") String newString, + @ToolParam( + name = "replace_all", + description = "Replace all occurrences (default: false)") + boolean replaceAll) { + EditResult r = + abstractFilesystem.edit(runtimeContext, path, oldString, newString, replaceAll); + return r.isSuccess() + ? "Edited " + r.path() + " (" + r.occurrences() + " replacement(s))" + : "Error: " + r.error(); + } + + @Tool(name = "grep_files", description = "Search file contents for a literal text pattern.") + public String grepFiles( + RuntimeContext runtimeContext, + @ToolParam(name = "pattern", description = "Literal text pattern to search for") + String pattern, + @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); + if (!r.isSuccess()) { + return "Error: " + r.error(); + } + List matches = r.matches(); + if (matches == null || matches.isEmpty()) { + return "No matches found"; + } + return matches.stream() + .map(m -> m.path() + ":" + m.line() + ":" + m.text()) + .collect(Collectors.joining("\n")); + } + + @Tool(name = "glob_files", description = "Find files matching a glob pattern.") + public String globFiles( + RuntimeContext runtimeContext, + @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); + if (!r.isSuccess()) { + return "Error: " + r.error(); + } + List files = r.matches(); + if (files == null || files.isEmpty()) { + return "No matching files found"; + } + return files.stream() + .map(f -> f.path() + (f.isDirectory() ? "/" : " (" + f.size() + " bytes)")) + .collect(Collectors.joining("\n")); + } + + @Tool(name = "list_files", description = "List files and directories at the given path.") + public String listFiles( + RuntimeContext runtimeContext, + @ToolParam(name = "path", description = "Directory path to list") String path) { + LsResult r = abstractFilesystem.ls(runtimeContext, path); + if (!r.isSuccess()) { + return "Error: " + r.error(); + } + List infos = r.entries(); + if (infos == null || infos.isEmpty()) { + return "Empty or not a directory: " + path; + } + return infos.stream() + .map( + f -> + (f.isDirectory() ? "[DIR] " : "[FILE] ") + + f.path() + + (f.isDirectory() ? "" : " (" + f.size() + " bytes)")) + .collect(Collectors.joining("\n")); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/MemoryGetTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/MemoryGetTool.java new file mode 100644 index 000000000..a667711e6 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/MemoryGetTool.java @@ -0,0 +1,80 @@ +/* + * 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.tool; + +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.nio.file.Path; +import java.util.List; + +/** + * Tool for reading specific lines from memory files, typically used after + * {@link MemorySearchTool} to fetch surrounding context. + */ +public class MemoryGetTool { + + private final WorkspaceManager workspaceManager; + + public MemoryGetTool(WorkspaceManager workspaceManager) { + this.workspaceManager = workspaceManager; + } + + @Tool( + name = "memory_get", + description = + "Read specific lines from a memory file. Use after memory_search to pull" + + " full context around matched lines. Path is relative to workspace.") + public String memoryGet( + @ToolParam( + name = "path", + description = + "Relative path to the memory file (e.g., MEMORY.md or" + + " memory/2026-04-01.md)") + String path, + @ToolParam(name = "startLine", description = "Start line number (1-based, inclusive)") + int startLine, + @ToolParam(name = "endLine", description = "End line number (1-based, inclusive)") + int endLine) { + if (path == null || path.isBlank()) { + return "Error: path is required"; + } + + Path resolved = workspaceManager.getWorkspace().resolve(path).normalize(); + if (!resolved.startsWith(workspaceManager.getWorkspace())) { + return "Error: path traversal not allowed"; + } + + String text = workspaceManager.readManagedWorkspaceFileUtf8(path); + if (text == null || text.isBlank()) { + return "Error: file not found: " + path; + } + + List lines = List.of(text.split("\n", -1)); + int start = Math.max(0, startLine - 1); + int end = Math.min(lines.size(), endLine); + + if (start >= lines.size()) { + return "Error: startLine " + startLine + " exceeds file length " + lines.size(); + } + + StringBuilder sb = new StringBuilder(); + for (int i = start; i < end; i++) { + sb.append(String.format("%d|%s%n", i + 1, lines.get(i))); + } + return sb.toString(); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/MemorySearchTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/MemorySearchTool.java new file mode 100644 index 000000000..d792f7360 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/MemorySearchTool.java @@ -0,0 +1,86 @@ +/* + * 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.tool; + +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.util.List; +import java.util.StringJoiner; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Tool for searching through persisted memories (MEMORY.md and memory/*.md files). + * + *

    Uses keyword-based search through all memory files visible via the configured + * {@link io.agentscope.harness.agent.filesystem.AbstractFilesystem} (works across Local, + * Sandbox, and Store backends). + */ +public class MemorySearchTool { + + private static final Logger log = LoggerFactory.getLogger(MemorySearchTool.class); + + private final WorkspaceManager workspaceManager; + + public MemorySearchTool(WorkspaceManager workspaceManager) { + this.workspaceManager = workspaceManager; + } + + @Tool( + name = "memory_search", + description = + "Search through long-term memory files (MEMORY.md and memory/*.md) for" + + " relevant information. Use before answering questions about prior" + + " work, decisions, dates, people, preferences, or todos.") + public String memorySearch( + @ToolParam(name = "query", description = "Keywords to search for in memory files") + String query) { + if (query == null || query.isBlank()) { + return "No query provided"; + } + + return keywordSearch(query); + } + + private String keywordSearch(String query) { + StringJoiner results = new StringJoiner("\n"); + int matchCount = 0; + + List memoryPaths = workspaceManager.listMemoryFilePaths(); + Pattern pattern = Pattern.compile(Pattern.quote(query), Pattern.CASE_INSENSITIVE); + + for (String relativePath : memoryPaths) { + String content = workspaceManager.readManagedWorkspaceFileUtf8(relativePath); + if (content == null || content.isEmpty()) { + continue; + } + String[] lines = content.split("\n", -1); + for (int i = 0; i < lines.length; i++) { + if (pattern.matcher(lines[i]).find()) { + results.add(String.format("Source: %s#%d: %s", relativePath, i + 1, lines[i])); + matchCount++; + } + } + } + + if (matchCount == 0) { + return "No matching memories found for: " + query; + } + return "Found " + matchCount + " matches:\n\n" + results; + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/SessionSearchTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/SessionSearchTool.java new file mode 100644 index 000000000..f37597afe --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/SessionSearchTool.java @@ -0,0 +1,307 @@ +/* + * 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.tool; + +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; +import io.agentscope.harness.agent.memory.session.SessionEntry; +import io.agentscope.harness.agent.memory.session.SessionTree; +import io.agentscope.harness.agent.workspace.WorkspaceConstants; +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Tool for searching past session transcripts and viewing session history. + * + *

    Operates exclusively on the local session cache. Remote synchronisation is handled by + * {@link io.agentscope.harness.agent.memory.session.SessionTree#load()} in write paths + * (e.g. {@link io.agentscope.harness.agent.memory.MemoryFlushManager}), keeping this tool + * lightweight and fast for in-process search. + */ +public class SessionSearchTool { + + private static final Logger log = LoggerFactory.getLogger(SessionSearchTool.class); + + private final WorkspaceManager workspaceManager; + + public SessionSearchTool(WorkspaceManager workspaceManager) { + this.workspaceManager = workspaceManager; + } + + @Tool( + name = "session_search", + description = + "Search past session transcripts for a keyword or phrase." + + " Returns matching entries with session context.") + public String sessionSearch( + @ToolParam(name = "query", description = "Search query (keyword or phrase)") + String query, + @ToolParam( + name = "agentId", + description = "Agent ID to search sessions for", + required = false) + String agentId, + @ToolParam( + name = "maxResults", + description = "Maximum number of results to return (default: 10)", + required = false) + Integer maxResults) { + if (query == null || query.isBlank()) { + return "Error: query is required"; + } + + int limit = maxResults != null && maxResults > 0 ? maxResults : 10; + String effectiveAgentId = agentId != null && !agentId.isBlank() ? agentId : null; + String lowerQuery = query.toLowerCase(); + + List results = new ArrayList<>(); + + List sessionFiles = listLogFiles(effectiveAgentId); + for (Path file : sessionFiles) { + if (results.size() >= limit) { + break; + } + searchInSessionFile(file, lowerQuery, results, limit); + } + + if (results.isEmpty()) { + return "No matches found for: " + query; + } + + StringBuilder sb = new StringBuilder(); + sb.append(String.format("Found %d matches for \"%s\":\n\n", results.size(), query)); + for (String result : results) { + sb.append(result).append("\n"); + } + return sb.toString(); + } + + @Tool( + name = "session_list", + description = "List available sessions for an agent, showing session IDs and metadata.") + public String sessionList( + @ToolParam(name = "agentId", description = "Agent ID to list sessions for") + String agentId) { + if (agentId == null || agentId.isBlank()) { + return "Error: agentId is required"; + } + + // Prefer the structured session-store index (already two-layer: remote then local). + String storeContent = + workspaceManager.readManagedWorkspaceFileUtf8( + WorkspaceConstants.AGENTS_DIR + + "/" + + agentId + + "/" + + WorkspaceConstants.SESSIONS_DIR + + "/" + + WorkspaceConstants.SESSIONS_STORE); + if (!storeContent.isBlank()) { + return storeContent; + } + + // List sessions from local cache only — remote sync is handled at write time. + Path sessionDir = workspaceManager.getSessionDir(agentId); + if (!Files.isDirectory(sessionDir)) { + return "No sessions found for agent: " + agentId; + } + + List sessionFiles = new ArrayList<>(); + try (Stream walk = Files.list(sessionDir)) { + walk.filter(Files::isRegularFile) + .filter( + p -> + p.getFileName() + .toString() + .endsWith(WorkspaceConstants.SESSION_CONTEXT_EXT)) + .forEach(sessionFiles::add); + } catch (IOException e) { + log.debug("Could not list local session dir for agent {}: {}", agentId, e.getMessage()); + } + + if (sessionFiles.isEmpty()) { + return "No sessions found for agent: " + agentId; + } + + StringBuilder sb = new StringBuilder(); + sb.append("Sessions for agent ").append(agentId).append(":\n"); + for (Path file : sessionFiles) { + String name = file.getFileName().toString(); + String sessionId = name.replace(WorkspaceConstants.SESSION_CONTEXT_EXT, ""); + sb.append(" - ").append(sessionId).append("\n"); + } + return sb.toString(); + } + + @Tool( + name = "session_history", + description = + "Get the conversation history for a specific session." + + " Returns the messages in the session.") + public String sessionHistory( + @ToolParam(name = "agentId", description = "Agent ID") String agentId, + @ToolParam(name = "sessionId", description = "Session ID") String sessionId, + @ToolParam( + name = "lastN", + description = "Number of recent messages to return (default: 20)", + required = false) + Integer lastN) { + if (agentId == null || agentId.isBlank() || sessionId == null || sessionId.isBlank()) { + return "Error: agentId and sessionId are required"; + } + + int limit = lastN != null && lastN > 0 ? lastN : 20; + + Path contextFile = workspaceManager.resolveSessionContextFile(agentId, sessionId); + if (!Files.isRegularFile(contextFile)) { + @SuppressWarnings("deprecation") + Path legacyFile = workspaceManager.resolveSessionFile(agentId, sessionId); + if (Files.isRegularFile(legacyFile)) { + log.debug("Falling back to legacy .json session file for {}", sessionId); + return readLegacySession(legacyFile, limit); + } + return "Session not found: " + sessionId; + } + + SessionTree tree = new SessionTree(contextFile, workspaceManager.getWorkspace(), null); + tree.load(); + + List messages = tree.getMessageEntries(); + int start = Math.max(0, messages.size() - limit); + + StringBuilder sb = new StringBuilder(); + sb.append( + String.format( + "Session %s (%d total messages, showing last %d):\n\n", + sessionId, messages.size(), Math.min(limit, messages.size()))); + for (int i = start; i < messages.size(); i++) { + SessionEntry.MessageEntry msg = messages.get(i); + String content = msg.getContent(); + if (content != null && content.length() > 500) { + content = content.substring(0, 500) + "... [truncated]"; + } + sb.append(String.format("[%s]: %s\n", msg.getRole(), content)); + } + return sb.toString(); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Collects all {@code .log.jsonl} files under the sessions directory for the given agent + * (or all agents when {@code agentId} is {@code null}). + * Only scans the local disk; remote-only sessions are handled via sessionList / sessionHistory. + */ + private List listLogFiles(String agentId) { + List files = new ArrayList<>(); + Path agentsDir = workspaceManager.getWorkspace().resolve(WorkspaceConstants.AGENTS_DIR); + if (!Files.isDirectory(agentsDir)) { + return files; + } + + if (agentId != null) { + Path sessionDir = agentsDir.resolve(agentId).resolve(WorkspaceConstants.SESSIONS_DIR); + collectLogFiles(sessionDir, files); + return files; + } + + try (Stream walk = Files.list(agentsDir)) { + walk.filter(Files::isDirectory) + .forEach( + agentDir -> + collectLogFiles( + agentDir.resolve(WorkspaceConstants.SESSIONS_DIR), + files)); + } catch (IOException e) { + // ignore + } + return files; + } + + private void collectLogFiles(Path sessionDir, List collector) { + if (!Files.isDirectory(sessionDir)) { + return; + } + try (Stream walk = Files.list(sessionDir)) { + walk.filter(p -> p.toString().endsWith(WorkspaceConstants.SESSION_LOG_EXT)) + .filter(Files::isRegularFile) + .forEach(collector::add); + } catch (IOException e) { + // ignore + } + } + + private void searchInSessionFile( + Path logFile, String lowerQuery, List results, int limit) { + try { + Path contextFile = + logFile.resolveSibling( + logFile.getFileName() + .toString() + .replace( + WorkspaceConstants.SESSION_LOG_EXT, + WorkspaceConstants.SESSION_CONTEXT_EXT)); + SessionTree tree = new SessionTree(contextFile, workspaceManager.getWorkspace(), null); + tree.load(); + + String relPath = workspaceManager.getWorkspace().relativize(logFile).toString(); + for (SessionEntry.MessageEntry msg : tree.getMessageEntries()) { + if (results.size() >= limit) { + break; + } + String content = msg.getContent(); + if (content != null && content.toLowerCase().contains(lowerQuery)) { + String preview = + content.length() > 200 ? content.substring(0, 200) + "..." : content; + results.add( + String.format( + " [%s] %s — [%s]: %s", + relPath, msg.getId(), msg.getRole(), preview)); + } + } + } catch (Exception e) { + // skip corrupted files + } + } + + private String readLegacySession(Path file, int limit) { + try { + String content = Files.readString(file); + String[] lines = content.split("\n"); + int start = Math.max(0, lines.length - limit); + StringBuilder sb = new StringBuilder(); + sb.append( + String.format( + "Legacy session (%d lines, showing last %d):\n", + lines.length, Math.min(limit, lines.length))); + for (int i = start; i < lines.length; i++) { + sb.append(lines[i]).append("\n"); + } + return sb.toString(); + } catch (IOException e) { + return "Error reading session file: " + e.getMessage(); + } + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/ShellExecuteTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/ShellExecuteTool.java new file mode 100644 index 000000000..698271e09 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/ShellExecuteTool.java @@ -0,0 +1,70 @@ +/* + * 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.tool; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; +import io.agentscope.harness.agent.filesystem.AbstractSandboxFilesystem; +import io.agentscope.harness.agent.filesystem.model.ExecuteResponse; + +/** + * Shell execution tool backed by a {@link AbstractSandboxFilesystem}. + */ +public class ShellExecuteTool { + + private final AbstractSandboxFilesystem sandbox; + + public ShellExecuteTool(AbstractSandboxFilesystem sandbox) { + this.sandbox = sandbox; + } + + /** + * @param runtimeContext per-call agent runtime injected by the framework (not an LLM argument); + * may be {@code null} when no merged context is available + */ + @Tool( + description = + "Execute a shell command. Use for git, npm, build, test, and other terminal" + + " operations. Returns combined output and exit code.") + public String execute( + RuntimeContext runtimeContext, + @ToolParam(name = "command", description = "Shell command to execute") String command, + @ToolParam( + name = "working_directory", + description = + "Working directory (relative to workspace root, optional)") + String workingDirectory, + @ToolParam(name = "timeout", description = "Timeout in seconds (default: 30)") + int timeout) { + String effectiveCommand = command; + if (workingDirectory != null && !workingDirectory.isBlank()) { + effectiveCommand = "cd " + workingDirectory + " && " + command; + } + + ExecuteResponse result = sandbox.execute(effectiveCommand, timeout > 0 ? timeout : 30); + + StringBuilder sb = new StringBuilder(); + sb.append("Exit code: ").append(result.exitCode()).append("\n"); + if (result.output() != null && !result.output().isBlank()) { + sb.append("\n").append(result.output()); + } + if (result.truncated()) { + sb.append("\n(output was truncated)"); + } + return sb.toString(); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/TaskTool.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/TaskTool.java new file mode 100644 index 000000000..1b725a04e --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/tool/TaskTool.java @@ -0,0 +1,202 @@ +/* + * 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.tool; + +import io.agentscope.core.tool.Tool; +import io.agentscope.core.tool.ToolParam; +import io.agentscope.harness.agent.subagent.task.BackgroundTask; +import io.agentscope.harness.agent.subagent.task.TaskRepository; +import io.agentscope.harness.agent.subagent.task.TaskStatus; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Collection; + +/** + * Unified tool for background task lifecycle management. Combines task result retrieval, + * cancellation, and listing into a single tool class. + * + *

      + *
    • {@code task_output} — retrieve result (blocking or non-blocking) + *
    • {@code task_cancel} — cancel a running task + *
    • {@code task_list} — list all tracked tasks with optional status filter + *
    + */ +public class TaskTool { + + private static final DateTimeFormatter ISO_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'").withZone(ZoneOffset.UTC); + + private final TaskRepository taskRepository; + + public TaskTool(TaskRepository taskRepository) { + this.taskRepository = taskRepository; + } + + @Tool( + name = "task_output", + description = + "Retrieve the output of a background subagent task. Use when agent_spawn or" + + " agent_send was called with timeout_seconds=0. Supports blocking wait" + + " for completion or non-blocking status peek (block=false).") + public String taskOutput( + @ToolParam( + name = "task_id", + description = + "The task_id returned by agent_spawn or agent_send when" + + " timeout_seconds was 0") + String taskId, + @ToolParam( + name = "block", + description = "Whether to wait for completion (default: true)", + required = false) + Boolean block, + @ToolParam( + name = "timeout", + description = + "Max wait time in milliseconds (default: 30000, max: 600000)", + required = false) + Long timeout) { + + if (taskId == null || taskId.isBlank()) { + return "Error: task_id is required"; + } + + BackgroundTask bgTask = taskRepository.getTask(taskId); + if (bgTask == null) { + return "Error: No background task found with ID: " + taskId; + } + + bgTask.updateLastCheckedAt(); + + boolean shouldBlock = block == null || block; + long timeoutMs = timeout != null ? Math.min(timeout, 600_000) : 30_000; + + if (shouldBlock && !bgTask.isCompleted()) { + try { + bgTask.waitForCompletion(timeoutMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return "Error: Wait for task interrupted"; + } + } + + return formatTaskDetail(bgTask); + } + + @Tool( + name = "task_cancel", + description = + "Cancel a running background task. Use to stop a task that is no longer" + + " needed. Has no effect on already-completed tasks.") + public String taskCancel( + @ToolParam(name = "task_id", description = "The task_id to cancel") String taskId) { + + if (taskId == null || taskId.isBlank()) { + return "Error: task_id is required"; + } + + BackgroundTask bgTask = taskRepository.getTask(taskId); + if (bgTask == null) { + return "Error: No background task found with ID: " + taskId; + } + + TaskStatus currentStatus = bgTask.getTaskStatus(); + if (currentStatus.isTerminal()) { + return "task_id: " + + taskId + + "\nstatus: " + + currentStatus.name().toLowerCase() + + "\nnote: Task already in terminal state, cannot cancel."; + } + + taskRepository.cancelTask(taskId); + return "task_id: " + taskId + "\nstatus: cancelled\nCancellation requested successfully."; + } + + @Tool( + name = "task_list", + description = + "List all tracked background tasks with their current statuses. Optionally" + + " filter by status (running, completed, failed, cancelled). Use to get an" + + " overview of all background work.") + public String taskList( + @ToolParam( + name = "status_filter", + description = + "Filter by status: running, completed, failed, cancelled, or" + + " omit for all tasks", + required = false) + String statusFilter) { + + TaskStatus filter = parseStatusFilter(statusFilter); + Collection tasks = taskRepository.listTasks(filter); + + if (tasks.isEmpty()) { + String filterDesc = + filter != null ? " with status '" + filter.name().toLowerCase() + "'" : ""; + return "No background tasks tracked" + filterDesc + "."; + } + + StringBuilder sb = new StringBuilder(); + sb.append(tasks.size()).append(" tracked task(s):\n"); + for (BackgroundTask task : tasks) { + sb.append("- task_id: ").append(task.getTaskId()); + if (task.getAgentId() != null) { + sb.append(" agent: ").append(task.getAgentId()); + } + sb.append(" status: ").append(task.getTaskStatus().name().toLowerCase()); + sb.append(" created: ").append(ISO_FORMATTER.format(task.getCreatedAt())); + sb.append('\n'); + } + return sb.toString().trim(); + } + + private static TaskStatus parseStatusFilter(String filter) { + if (filter == null || filter.isBlank() || "all".equalsIgnoreCase(filter.trim())) { + return null; + } + try { + return TaskStatus.valueOf(filter.trim().toUpperCase()); + } catch (IllegalArgumentException e) { + return null; + } + } + + private static String formatTaskDetail(BackgroundTask task) { + StringBuilder sb = new StringBuilder(); + sb.append("task_id: ").append(task.getTaskId()).append('\n'); + if (task.getAgentId() != null) { + sb.append("agent_id: ").append(task.getAgentId()).append('\n'); + } + sb.append("status: ").append(task.getStatus()).append('\n'); + sb.append("created_at: ").append(ISO_FORMATTER.format(task.getCreatedAt())).append('\n'); + + if (task.isCompleted() && task.getResult() != null) { + sb.append("\nResult:\n").append(task.getResult()); + } else if (task.getError() != null) { + Exception err = task.getError(); + sb.append("\nError:\n").append(err.getMessage()); + if (err.getCause() != null) { + sb.append("\nCause: ").append(err.getCause().getMessage()); + } + } else if (!task.isCompleted()) { + sb.append("\nTask still running..."); + } else { + sb.append("\nTask completed with no result."); + } + return sb.toString(); + } +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspaceConstants.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspaceConstants.java new file mode 100644 index 000000000..84ce87758 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspaceConstants.java @@ -0,0 +1,49 @@ +/* + * 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; + +/** Path constants for the workspace directory structure. */ +public final class WorkspaceConstants { + + private WorkspaceConstants() {} + + public static final String DEFAULT_WORKSPACE_ROOT = ".agentscope/workspace"; + + public static final String AGENTS_MD = "AGENTS.md"; + public static final String MEMORY_MD = "MEMORY.md"; + + public static final String MEMORY_DIR = "memory"; + public static final String SKILLS_DIR = "skills"; + public static final String KNOWLEDGE_DIR = "knowledge"; + public static final String KNOWLEDGE_MD = "KNOWLEDGE.md"; + public static final String RULES_DIR = "rules"; + + public static final String AGENTS_DIR = "agents"; + public static final String SESSIONS_DIR = "sessions"; + + /** + * Per-agent session store filename under {@code agents/<agentId>/sessions/} + */ + public static final String SESSIONS_STORE = "sessions.json"; + + /** JSONL session context file extension (LLM-facing, may be compacted). */ + public static final String SESSION_CONTEXT_EXT = ".jsonl"; + + /** JSONL session log file extension (full history, append-only, never compacted). */ + public static final String SESSION_LOG_EXT = ".log.jsonl"; + + public static final String SUBAGENT_YML = "subagent.yml"; +} diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspaceManager.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspaceManager.java new file mode 100644 index 000000000..fb7f78b22 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/workspace/WorkspaceManager.java @@ -0,0 +1,531 @@ +/* + * 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 static io.agentscope.harness.agent.workspace.WorkspaceConstants.AGENTS_DIR; +import static io.agentscope.harness.agent.workspace.WorkspaceConstants.AGENTS_MD; +import static io.agentscope.harness.agent.workspace.WorkspaceConstants.KNOWLEDGE_DIR; +import static io.agentscope.harness.agent.workspace.WorkspaceConstants.KNOWLEDGE_MD; +import static io.agentscope.harness.agent.workspace.WorkspaceConstants.MEMORY_DIR; +import static io.agentscope.harness.agent.workspace.WorkspaceConstants.MEMORY_MD; +import static io.agentscope.harness.agent.workspace.WorkspaceConstants.SESSIONS_DIR; +import static io.agentscope.harness.agent.workspace.WorkspaceConstants.SESSIONS_STORE; +import static io.agentscope.harness.agent.workspace.WorkspaceConstants.SKILLS_DIR; +import static io.agentscope.harness.agent.workspace.WorkspaceConstants.SUBAGENT_YML; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.filesystem.AbstractFilesystem; +import io.agentscope.harness.agent.filesystem.model.FileInfo; +import io.agentscope.harness.agent.filesystem.model.GlobResult; +import io.agentscope.harness.agent.filesystem.model.ReadResult; +import io.agentscope.harness.agent.store.NamespaceFactory; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Stream; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Stateless accessor for workspace content using a two-layer read architecture. + * + *

    Read path: For every read (AGENTS.md, MEMORY.md, knowledge, subagent.yml, + * etc.), the {@link AbstractFilesystem} is queried first. If it returns non-empty content, that + * content is used (filesystem overrides). Otherwise, the local workspace disk is read as a + * fallback. The filesystem layer applies user/session scoping transparently via + * {@link NamespaceFactory}. + * + *

    Write path: All writes (memory, sessions, etc.) go through the + * {@link AbstractFilesystem}. + * + *

    Listing: File listings (memory files, knowledge files, session logs) union + * results from both the filesystem layer and local disk, deduplicating by relative path. + * + *

    Expected layout: + * + *

    + * workspace/
    + * ├── AGENTS.md
    + * ├── MEMORY.md
    + * ├── memory/YYYY-MM-DD.md
    + * ├── skills/<skill-name>/SKILL.md
    + * ├── knowledge/KNOWLEDGE.md
    + * ├── knowledge/*
    + * ├── agents/<agentId>/sessions/sessions.json
    + * ├── agents/<agentId>/sessions/<sessionId>.log.jsonl
    + * └── subagent.yml
    + * 
    + */ +public class WorkspaceManager { + + private static final RuntimeContext DEFAULT_FS_RUNTIME = RuntimeContext.empty(); + + private static final Logger log = LoggerFactory.getLogger(WorkspaceManager.class); + private static final ObjectMapper SESSION_STORE_JSON = new ObjectMapper(); + + private final Path workspace; + private final AbstractFilesystem filesystem; + + public WorkspaceManager(Path workspace) { + this(workspace, null); + } + + public WorkspaceManager(Path workspace, AbstractFilesystem filesystem) { + this.workspace = workspace; + this.filesystem = filesystem; + } + + public AbstractFilesystem getFilesystem() { + return filesystem; + } + + /** + * Validates the workspace exists and key files are present. Logs warnings for anything + * missing. Called once at HarnessAgent build time. + */ + public void validate() { + if (!Files.isDirectory(workspace)) { + log.warn( + "Workspace directory does not exist: {}. " + + "Please create it and add AGENTS.md.", + workspace.toAbsolutePath()); + return; + } + if (!Files.isRegularFile(workspace.resolve(AGENTS_MD))) { + log.warn( + "AGENTS.md not found in workspace: {}. " + + "AGENTS.md defines persona and local conventions for the agent.", + workspace.toAbsolutePath()); + } + } + + public Path getWorkspace() { + return workspace; + } + + /** Reads AGENTS.md content, returns empty string if not found. */ + public String readAgentsMd() { + return readWithOverride(AGENTS_MD); + } + + /** Reads KNOWLEDGE.md content from the knowledge directory. */ + public String readKnowledgeMd() { + return readWithOverride(KNOWLEDGE_DIR + "/" + KNOWLEDGE_MD); + } + + /** Reads MEMORY.md content (two-layer: filesystem override, local fallback). */ + public String readMemoryMd() { + return readWithOverride(MEMORY_MD); + } + + /** + * Reads a UTF-8 file under the workspace, using the two-layer pattern: + * filesystem first, then local disk fallback. + */ + public String readManagedWorkspaceFileUtf8(String relativePath) { + if (relativePath == null || relativePath.isBlank()) { + return ""; + } + String normalized = normalizeRelativePath(relativePath); + if (normalized.isEmpty()) { + return ""; + } + Path resolved = workspace.resolve(normalized).normalize(); + if (!resolved.startsWith(workspace)) { + return ""; + } + return readWithOverride(normalized); + } + + /** Reads subagent.yml content (two-layer: filesystem override, local fallback). */ + public String readSubagentYml() { + return readWithOverride(SUBAGENT_YML); + } + + public Path getMemoryDir() { + return workspace.resolve(MEMORY_DIR); + } + + public Path getSkillsDir() { + return workspace.resolve(SKILLS_DIR); + } + + public Path getKnowledgeDir() { + return workspace.resolve(KNOWLEDGE_DIR); + } + + /** Lists all files under the knowledge directory tree (union of filesystem + local disk). */ + public List listKnowledgeFiles() { + Set relativePaths = new LinkedHashSet<>(); + + if (filesystem != null) { + GlobResult glob = filesystem.glob(DEFAULT_FS_RUNTIME, "*", KNOWLEDGE_DIR); + if (glob.isSuccess() && glob.matches() != null) { + for (FileInfo fi : glob.matches()) { + if (fi.path() != null && !fi.path().isBlank()) { + relativePaths.add(normalizeRelativePath(fi.path().trim())); + } + } + } + } + + Path dir = getKnowledgeDir(); + if (Files.isDirectory(dir)) { + try (Stream walk = Files.walk(dir)) { + walk.filter(Files::isRegularFile) + .forEach( + p -> { + String rel = + workspace + .relativize(p.normalize()) + .toString() + .replace('\\', '/'); + relativePaths.add(rel); + }); + } catch (IOException e) { + log.warn("Failed to list knowledge files: {}", e.getMessage()); + } + } + + List result = new ArrayList<>(); + for (String rel : relativePaths) { + result.add(workspace.resolve(rel)); + } + return result; + } + + public Path getSessionDir(String agentId) { + return workspace.resolve(AGENTS_DIR).resolve(agentId).resolve(SESSIONS_DIR); + } + + /** + * Returns the legacy session file path (.json) without creating directories. + * + * @deprecated Use {@link #resolveSessionContextFile(String, String)} for the JSONL format. + */ + @Deprecated + public Path resolveSessionFile(String agentId, String sessionId) { + return getSessionDir(agentId).resolve(sessionId + ".json"); + } + + /** Returns the JSONL session context file path (LLM-facing, compacted). */ + public Path resolveSessionContextFile(String agentId, String sessionId) { + return getSessionDir(agentId).resolve(sessionId + WorkspaceConstants.SESSION_CONTEXT_EXT); + } + + /** Returns the JSONL session log file path (full history, append-only). */ + public Path resolveSessionLogFile(String agentId, String sessionId) { + return getSessionDir(agentId).resolve(sessionId + WorkspaceConstants.SESSION_LOG_EXT); + } + + /** + * Appends UTF-8 text to a workspace-relative file, creating parent directories when needed. + * All writes go through the {@link AbstractFilesystem}. + */ + public void appendUtf8WorkspaceRelative(String relativePath, String content) { + if (relativePath == null || content == null) { + return; + } + String normalized = normalizeRelativePath(relativePath); + if (normalized.isEmpty()) { + return; + } + if (filesystem == null) { + appendLocalFile(normalized, content); + return; + } + ReadResult rr = filesystem.read(DEFAULT_FS_RUNTIME, normalized, 0, 0); + String existing = ""; + if (rr.isSuccess() && rr.fileData() != null && rr.fileData().content() != null) { + existing = rr.fileData().content(); + } + String merged = existing + content; + filesystem.uploadFiles( + DEFAULT_FS_RUNTIME, + List.of(Map.entry(normalized, merged.getBytes(StandardCharsets.UTF_8)))); + } + + /** + * Upserts metadata for a session in {@code agents/<agentId>/sessions/sessions.json} + * (small mutable JSON, keyed by {@code sessionId}). + */ + public void updateSessionIndex(String agentId, String sessionId, String summary) { + if (agentId == null || agentId.isBlank() || sessionId == null || sessionId.isBlank()) { + return; + } + String rel = AGENTS_DIR + "/" + agentId + "/" + SESSIONS_DIR + "/" + SESSIONS_STORE; + String existing = readWritableWorkspaceRelativeUtf8(rel); + ObjectNode root = parseSessionStoreOrEmpty(existing); + ObjectNode sessions = ensureSessionsObject(root); + ObjectNode entry = SESSION_STORE_JSON.createObjectNode(); + entry.put("summary", summary != null ? summary : ""); + entry.put("updatedAt", java.time.Instant.now().toString()); + sessions.set(sessionId, entry); + if (!root.has("version")) { + root.put("version", 1); + } + try { + String serialized = + SESSION_STORE_JSON.writerWithDefaultPrettyPrinter().writeValueAsString(root); + writeUtf8WorkspaceRelative(rel, serialized); + } catch (IOException e) { + log.warn("Failed to write session store {}: {}", rel, e.getMessage()); + } + } + + private ObjectNode parseSessionStoreOrEmpty(String json) { + if (json == null || json.isBlank()) { + return SESSION_STORE_JSON.createObjectNode(); + } + try { + var node = SESSION_STORE_JSON.readTree(json); + if (node instanceof ObjectNode on) { + return on; + } + } catch (IOException e) { + log.warn("Corrupt or unreadable session store, reinitializing: {}", e.getMessage()); + } + return SESSION_STORE_JSON.createObjectNode(); + } + + private ObjectNode ensureSessionsObject(ObjectNode root) { + var n = root.get("sessions"); + if (n instanceof ObjectNode on) { + return on; + } + ObjectNode fresh = SESSION_STORE_JSON.createObjectNode(); + root.set("sessions", fresh); + return fresh; + } + + private String readWritableWorkspaceRelativeUtf8(String relativePath) { + String normalized = normalizeRelativePath(relativePath); + if (normalized.isEmpty()) { + return ""; + } + return readWithOverride(normalized); + } + + /** Overwrites a workspace-relative UTF-8 file. All writes go through the filesystem. */ + public void writeUtf8WorkspaceRelative(String relativePath, String content) { + if (relativePath == null || content == null) { + return; + } + String normalized = normalizeRelativePath(relativePath); + if (normalized.isEmpty()) { + return; + } + if (filesystem == null) { + writeLocalFile(normalized, content); + return; + } + filesystem.uploadFiles( + DEFAULT_FS_RUNTIME, + List.of(Map.entry(normalized, content.getBytes(StandardCharsets.UTF_8)))); + } + + // ==================== Two-layer read/write helpers ==================== + + /** + * Two-layer read: filesystem first (namespaced by {@link + * NamespaceFactory}), local disk fallback. + */ + private String readWithOverride(String relativePath) { + String fsContent = readTextThroughFilesystem(relativePath); + if (!fsContent.isEmpty()) { + return fsContent; + } + return readFileQuietly(workspace.resolve(relativePath)); + } + + private String readFileQuietly(Path path) { + if (!Files.isRegularFile(path)) { + return ""; + } + try { + return Files.readString(path, StandardCharsets.UTF_8); + } catch (IOException e) { + log.warn("Failed to read {}: {}", path, e.getMessage()); + return ""; + } + } + + private String readTextThroughFilesystem(String filePath) { + if (filesystem == null) { + return ""; + } + ReadResult r = filesystem.read(DEFAULT_FS_RUNTIME, filePath, 0, 0); + if (!r.isSuccess() || r.fileData() == null) { + return ""; + } + String c = r.fileData().content(); + return c != null ? c : ""; + } + + private void appendLocalFile(String relativePath, String content) { + Path local = workspace.resolve(relativePath).normalize(); + if (!local.startsWith(workspace)) { + log.warn("Refusing to write outside workspace: {}", relativePath); + return; + } + try { + if (local.getParent() != null) { + Files.createDirectories(local.getParent()); + } + Files.writeString( + local, + content, + StandardCharsets.UTF_8, + java.nio.file.StandardOpenOption.CREATE, + java.nio.file.StandardOpenOption.APPEND); + } catch (IOException e) { + log.warn("Failed to append {}: {}", local, e.getMessage()); + } + } + + private void writeLocalFile(String relativePath, String content) { + Path local = workspace.resolve(relativePath).normalize(); + if (!local.startsWith(workspace)) { + log.warn("Refusing to write outside workspace: {}", relativePath); + return; + } + try { + if (local.getParent() != null) { + Files.createDirectories(local.getParent()); + } + Files.writeString( + local, + content, + StandardCharsets.UTF_8, + java.nio.file.StandardOpenOption.CREATE, + java.nio.file.StandardOpenOption.TRUNCATE_EXISTING, + java.nio.file.StandardOpenOption.WRITE); + } catch (IOException e) { + log.warn("Failed to write {}: {}", local, e.getMessage()); + } + } + + static String normalizeRelativePath(String relativePath) { + if (relativePath == null || relativePath.isBlank()) { + return ""; + } + String s = relativePath.replace('\\', '/').stripLeading(); + while (s.startsWith("/")) { + s = s.substring(1); + } + return s; + } + + /** + * Returns workspace-relative paths of all memory files ({@code MEMORY.md} and {@code + * memory/*.md}). Unions results from the {@link AbstractFilesystem} layer and the local disk, + * deduplicating by relative path. + */ + public List listMemoryFilePaths() { + Set paths = new LinkedHashSet<>(); + + if (filesystem != null) { + ReadResult memMd = filesystem.read(DEFAULT_FS_RUNTIME, MEMORY_MD, 0, 1); + if (memMd.isSuccess()) { + paths.add(MEMORY_MD); + } + GlobResult glob = filesystem.glob(DEFAULT_FS_RUNTIME, "*.md", MEMORY_DIR); + if (glob.isSuccess() && glob.matches() != null) { + for (FileInfo fi : glob.matches()) { + if (fi.path() != null && !fi.path().isBlank()) { + String rel = normalizeRelativePath(fi.path().trim()); + if (!rel.isEmpty()) { + paths.add(rel); + } + } + } + } + } + + if (Files.isRegularFile(workspace.resolve(MEMORY_MD))) { + paths.add(MEMORY_MD); + } + Path memDir = getMemoryDir(); + if (Files.isDirectory(memDir)) { + try (Stream walk = Files.list(memDir)) { + walk.filter(p -> p.toString().endsWith(".md")) + .filter(Files::isRegularFile) + .forEach(p -> paths.add(MEMORY_DIR + "/" + p.getFileName())); + } catch (IOException e) { + log.warn("Failed to list memory dir: {}", e.getMessage()); + } + } + return new ArrayList<>(paths); + } + + /** + * Lists workspace-relative paths of all session log files ({@code *.log.jsonl}). + * Unions results from the {@link AbstractFilesystem} layer and the local disk. + */ + public List listSessionLogFiles() { + Set paths = new LinkedHashSet<>(); + + if (filesystem != null) { + GlobResult glob = filesystem.glob(DEFAULT_FS_RUNTIME, "*.log.jsonl", AGENTS_DIR); + if (glob.isSuccess() && glob.matches() != null) { + for (FileInfo fi : glob.matches()) { + if (fi.path() != null && !fi.path().isBlank()) { + String rel = normalizeRelativePath(fi.path().trim()); + if (!rel.isEmpty()) { + paths.add(rel); + } + } + } + } + } + + Path agentsDir = workspace.resolve(AGENTS_DIR); + if (Files.isDirectory(agentsDir)) { + try (Stream walk = Files.walk(agentsDir)) { + walk.filter(Files::isRegularFile) + .filter(p -> p.toString().endsWith(WorkspaceConstants.SESSION_LOG_EXT)) + .forEach( + p -> { + String rel = + workspace + .relativize(p.normalize()) + .toString() + .replace('\\', '/'); + paths.add(rel); + }); + } catch (IOException e) { + log.warn("Failed to list session log files: {}", e.getMessage()); + } + } + return new ArrayList<>(paths); + } + + /** Workspace-relative path for indexing. */ + public String toWorkspaceRelativeString(Path absoluteUnderWorkspace) { + return workspace + .relativize(absoluteUnderWorkspace.normalize()) + .toString() + .replace('\\', '/'); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDistributedSandboxTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDistributedSandboxTest.java new file mode 100644 index 000000000..f8a121cc0 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentDistributedSandboxTest.java @@ -0,0 +1,205 @@ +/* + * 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; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.Model; +import io.agentscope.core.session.Session; +import io.agentscope.harness.agent.filesystem.LocalFilesystemSpec; +import io.agentscope.harness.agent.filesystem.RemoteFilesystemSpec; +import io.agentscope.harness.agent.sandbox.SandboxDistributedOptions; +import io.agentscope.harness.agent.sandbox.filesystem.DockerFilesystemSpec; +import io.agentscope.harness.agent.sandbox.snapshot.LocalSnapshotSpec; +import io.agentscope.harness.agent.store.BaseStore; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import reactor.core.publisher.Flux; + +class HarnessAgentDistributedSandboxTest { + + @TempDir Path workspace; + + @Test + void sandboxDistributed_requiresSandboxFilesystemMode() { + IllegalStateException ex = + assertThrows( + IllegalStateException.class, + () -> + HarnessAgent.builder() + .name("agent") + .model(stubModel("ok")) + .workspace(workspace) + .filesystem(new LocalFilesystemSpec()) + .sandboxDistributed( + SandboxDistributedOptions.builder().build()) + .build()); + assertEquals( + true, + ex.getMessage().contains("requires sandbox mode"), + "should fail-fast when sandboxDistributed is used outside sandbox mode"); + } + + @Test + void sandboxMode_withLocalSession_failsFastByDefault() { + // Mode 2 (SandboxFilesystemSpec) now validates automatically — no sandboxDistributed() + // needed. + IllegalStateException ex = + assertThrows( + IllegalStateException.class, + () -> + HarnessAgent.builder() + .name("agent") + .model(stubModel("ok")) + .workspace(workspace) + .filesystem(new DockerFilesystemSpec()) + .build()); + assertEquals( + true, + ex.getMessage().contains("distributed Session backend"), + "sandbox mode should fail-fast when effective session remains local" + + " WorkspaceSession"); + } + + @Test + void sandboxMode_explicitSandboxDistributed_alsoFailsOnLocalSession() { + // Explicit sandboxDistributed() with default requireDistributed=true still fails. + IllegalStateException ex = + assertThrows( + IllegalStateException.class, + () -> + HarnessAgent.builder() + .name("agent") + .model(stubModel("ok")) + .workspace(workspace) + .filesystem(new DockerFilesystemSpec()) + .sandboxDistributed( + SandboxDistributedOptions.builder().build()) + .build()); + assertEquals( + true, + ex.getMessage().contains("distributed Session backend"), + "should fail-fast when effective session remains local"); + } + + @Test + void sandboxDistributed_appliesSnapshotOverride() { + Session distributedSession = mock(Session.class); + DockerFilesystemSpec spec = new DockerFilesystemSpec(); + spec.isolationScope(IsolationScope.AGENT); + LocalSnapshotSpec snapshotSpec = new LocalSnapshotSpec(workspace.resolve("snapshots")); + + SandboxDistributedOptions options = + SandboxDistributedOptions.builder() + .session(distributedSession) + .snapshotSpec(snapshotSpec) + .build(); + + assertDoesNotThrow( + () -> + HarnessAgent.builder() + .name("agent") + .model(stubModel("ok")) + .workspace(workspace) + .filesystem(spec) + .sandboxDistributed(options) + .build()); + + assertEquals(IsolationScope.AGENT, spec.getIsolationScope()); + assertInstanceOf(LocalSnapshotSpec.class, spec.toSandboxContext().getSnapshotSpec()); + } + + @Test + void sandboxMode_requireDistributedFalse_allowsLocalSession() { + // Single-node sandbox use: opt out of distributed validation explicitly. + assertDoesNotThrow( + () -> + HarnessAgent.builder() + .name("agent") + .model(stubModel("ok")) + .workspace(workspace) + .filesystem(new DockerFilesystemSpec()) + .sandboxDistributed( + SandboxDistributedOptions.builder() + .requireDistributed(false) + .build()) + .build()); + } + + @Test + void remoteFilesystemMode_withLocalSession_failsFast() { + // Mode 1 (RemoteFilesystemSpec) always requires a distributed Session. + BaseStore store = mock(BaseStore.class); + IllegalStateException ex = + assertThrows( + IllegalStateException.class, + () -> + HarnessAgent.builder() + .name("agent") + .model(stubModel("ok")) + .workspace(workspace) + .filesystem(new RemoteFilesystemSpec(store)) + .build()); + assertEquals( + true, + ex.getMessage().contains("RemoteFilesystemSpec"), + "Mode 1 should fail-fast when effective session is local WorkspaceSession"); + } + + @Test + void remoteFilesystemMode_withDistributedSession_succeeds() { + // Mode 1 with a distributed Session should build successfully. + BaseStore store = mock(BaseStore.class); + Session distributedSession = mock(Session.class); + assertDoesNotThrow( + () -> + HarnessAgent.builder() + .name("agent") + .model(stubModel("ok")) + .workspace(workspace) + .filesystem(new RemoteFilesystemSpec(store)) + .session(distributedSession) + .build()); + } + + private static Model stubModel(String assistantText) { + Model model = mock(Model.class); + when(model.getModelName()).thenReturn("stub-model"); + ChatResponse chunk = + new ChatResponse( + "stub-id", + List.of( + io.agentscope.core.message.TextBlock.builder() + .text(assistantText) + .build()), + null, + Map.of(), + "stop"); + when(model.stream(anyList(), any(), any())).thenReturn(Flux.just(chunk)); + return model; + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentIntegrationExampleTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentIntegrationExampleTest.java new file mode 100644 index 000000000..dc4a3585a --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentIntegrationExampleTest.java @@ -0,0 +1,267 @@ +/* + * 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; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.Model; +import io.agentscope.harness.agent.hook.SubagentsHook.SubagentEntry; +import io.agentscope.harness.agent.workspace.WorkspaceConstants; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Tag; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Flux; + +/** + * Integration-style examples for {@link HarnessAgent}: a realistic workspace on disk, full builder + * wiring, and an end-to-end {@link #call(Msg, RuntimeContext)} path. + * + *

    Example workspace layout (what the tests materialize under {@link TempDir}): + * + *

    + * workspace/
    + * ├── AGENTS.md              # persona / local rules
    + * ├── MEMORY.md              # optional long-term scratch (loaded into <memory_context>)
    + * ├── knowledge/
    + * │   └── KNOWLEDGE.md       # optional domain summary
    + * └── subagents/
    + *     ├── helper.md          # YAML front matter + body as sys prompt
    + *     └── reviewer.md        # second spec example
    + * 
    + * + *

    These tests use a stub {@link Model} (no API keys). Tag {@code integration} lets you filter + * them in the IDE or via JUnit Platform if you add {@code groups} later. + */ +@Tag("integration") +class HarnessAgentIntegrationExampleTest { + + @TempDir Path workspace; + + /** + * Materializes the layout above, builds the main agent, runs one turn, and asserts the stub + * reply. The model capture shows that session, subagent docs, and workspace files reached the + * LLM message list. + */ + @Test + void example_fullWorkspace_singleTurn_seesSessionSubagentsAndWorkspaceContext() + throws Exception { + String agentsPersona = "INTEGRATION_AGENTS_PERSONA_001"; + String memoryNote = "INTEGRATION_MEMORY_NOTE_002"; + String knowledgeLine = "INTEGRATION_KNOWLEDGE_LINE_003"; + String helperSubId = "integration-helper-sub"; + String reviewerSubId = "integration-md-sub"; + + Files.createDirectories(workspace); + Files.writeString( + workspace.resolve(WorkspaceConstants.AGENTS_MD), + "# Agent\n\n" + agentsPersona + "\n"); + Files.writeString(workspace.resolve(WorkspaceConstants.MEMORY_MD), memoryNote); + + Path knowledgeDir = workspace.resolve(WorkspaceConstants.KNOWLEDGE_DIR); + Files.createDirectories(knowledgeDir); + Files.writeString(knowledgeDir.resolve(WorkspaceConstants.KNOWLEDGE_MD), knowledgeLine); + + Path subagentsDir = workspace.resolve("subagents"); + Files.createDirectories(subagentsDir); + Files.writeString( + subagentsDir.resolve("helper.md"), + """ + --- + name: %s + description: First markdown-defined helper for integration example + --- + Reply with YAML_OK only. + """ + .formatted(helperSubId)); + Files.writeString( + subagentsDir.resolve("reviewer.md"), + """ + --- + name: %s + description: Second markdown-defined helper for integration example + maxIters: 5 + --- + + You only reply MD_OK. + """ + .formatted(reviewerSubId)); + + Model model = stubModel("integration-main-reply"); + HarnessAgent agent = + HarnessAgent.builder() + .name("integration-main") + .description("integration example main agent") + .sysPrompt("You are the main agent in an integration test.") + .model(model) + .workspace(workspace) + .build(); + + Msg reply = + agent.call( + userText("Run the integration scenario."), + RuntimeContext.builder().sessionId("integration-session-1").build()) + .block(); + + assertTrue(reply.getTextContent().contains("integration-main-reply")); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(model, atLeast(1)).stream(captor.capture(), any(), any()); + String combined = + captor.getAllValues().stream() + .map(HarnessAgentIntegrationExampleTest::joinAllText) + .filter(s -> s.contains("## Session Context")) + .findFirst() + .orElse(""); + + assertTrue( + combined.contains("## Session Context"), + "Session context should be injected; model saw: " + + captor.getAllValues().stream() + .map(HarnessAgentIntegrationExampleTest::joinAllText) + .toList()); + // Current WorkspaceContextHook uses markdown (##) guidance + XML blocks + assertTrue( + combined.contains("## Domain Knowledge") || combined.contains("## Workspace"), + "expected workspace guidance sections"); + assertTrue(combined.contains("`AGENTS.md`") || combined.contains("agents_context")); + assertTrue( + combined.contains(agentsPersona), "AGENTS.md should appear under workspace hook"); + assertTrue(combined.contains("memory_context") || combined.contains("MEMORY.md")); + assertTrue(combined.contains(memoryNote)); + assertTrue( + combined.contains("domain_knowledge_context") || combined.contains("KNOWLEDGE.md")); + assertTrue(combined.contains(knowledgeLine)); + assertTrue( + combined.contains("## Subagents") || combined.contains("Subagents:"), + "subagent list should be injected into the system prompt"); + assertTrue(combined.contains("`" + helperSubId + "`")); + assertTrue(combined.contains("`" + reviewerSubId + "`")); + } + + /** + * Uses the same workspace discovery as the builder, obtains a {@link SubagentEntry} for a + * markdown-defined id under {@code subagents/}, runs {@code factory().create()} and {@link + * Agent#call(List)} to prove the delegated {@link HarnessAgent} is wired with the spec name + * and prompt. + */ + @Test + void example_subagentFactory_markdownSpec_runsChildHarnessAgent() throws Exception { + Files.createDirectories(workspace); + Files.writeString(workspace.resolve(WorkspaceConstants.AGENTS_MD), "# root\n"); + + String childId = "integration-child-spawn"; + Path subagentsDir = workspace.resolve("subagents"); + Files.createDirectories(subagentsDir); + Files.writeString( + subagentsDir.resolve("child.md"), + """ + --- + name: %s + description: Child agent for factory integration + --- + Child system prompt marker INTEGRATION_CHILD_SYS + """ + .formatted(childId)); + + Model model = mock(Model.class); + when(model.getModelName()).thenReturn("stub-model"); + ChatResponse childChunk = + new ChatResponse( + "child-id", + List.of(TextBlock.builder().text("integration-child-reply").build()), + null, + Map.of(), + "stop"); + when(model.stream(anyList(), any(), any())).thenReturn(Flux.just(childChunk)); + + List entries = + HarnessAgent.builder() + .model(model) + .workspace(workspace) + .buildSubagentEntries(workspace); + + SubagentEntry child = + entries.stream() + .filter(e -> childId.equals(e.name())) + .findFirst() + .orElseThrow( + () -> new AssertionError("missing subagent entry: " + childId)); + + Agent sub = child.factory().create(); + assertInstanceOf(HarnessAgent.class, sub); + assertEquals(childId, sub.getName()); + + Msg subReply = sub.call(List.of(userText("task for child"))).block(); + assertTrue(subReply.getTextContent().contains("integration-child-reply")); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(model, atLeast(1)).stream(captor.capture(), any(), any()); + boolean childSysSeen = + captor.getAllValues().stream() + .map(HarnessAgentIntegrationExampleTest::joinAllText) + .anyMatch(s -> s.contains("INTEGRATION_CHILD_SYS")); + assertTrue( + childSysSeen, + "child HarnessAgent should use spec sysPrompt in its system prompt bundle"); + } + + private static Msg userText(String text) { + return Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text(text).build()) + .build(); + } + + private static String joinAllText(List msgs) { + return msgs.stream().map(Msg::getTextContent).collect(Collectors.joining("\n")); + } + + private static Model stubModel(String assistantText) { + Model model = mock(Model.class); + when(model.getModelName()).thenReturn("stub-model"); + ChatResponse chunk = + new ChatResponse( + "stub-id", + List.of(TextBlock.builder().text(assistantText).build()), + null, + Map.of(), + "stop"); + when(model.stream(anyList(), any(), any())).thenReturn(Flux.just(chunk)); + return model; + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentModelStringTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentModelStringTest.java new file mode 100644 index 000000000..be7836e9e --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentModelStringTest.java @@ -0,0 +1,125 @@ +/* + * 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; + +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelRegistry; +import io.agentscope.harness.agent.filesystem.LocalFilesystem; +import io.agentscope.harness.agent.hook.SubagentsHook.SubagentEntry; +import io.agentscope.harness.agent.subagent.SubagentSpec; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import reactor.core.publisher.Flux; + +class HarnessAgentModelStringTest { + + @TempDir Path workspace; + + @BeforeEach + void setUp() throws Exception { + ModelRegistry.reset(); + Files.createDirectories(workspace); + } + + @AfterEach + void tearDown() { + ModelRegistry.reset(); + } + + @Test + void builder_modelString_resolvesViaRegistry() { + Model registered = stubModel("ok"); + ModelRegistry.register("reg-main", registered); + + HarnessAgent agent = + HarnessAgent.builder() + .name("t") + .model("reg-main") + .workspace(workspace) + .abstractFilesystem(new LocalFilesystem(workspace)) + .build(); + + assertSame(registered, agent.getDelegate().getModel()); + } + + @Test + void builder_modelString_unknownId_throws() { + assertThrows( + IllegalArgumentException.class, + () -> + HarnessAgent.builder() + .name("t") + .model("no-such-registry-model") + .workspace(workspace) + .abstractFilesystem(new LocalFilesystem(workspace)) + .build()); + } + + @Test + void subagentSpec_model_resolvedByDefaultResolver() { + Model main = stubModel("main-reply"); + Model sub = stubModel("sub-reply"); + ModelRegistry.register("reg-sub", sub); + + SubagentSpec spec = new SubagentSpec("sa", "subagent"); + spec.setSysPrompt("You are a test subagent."); + spec.setModel("reg-sub"); + + List entries = + HarnessAgent.builder() + .name("main") + .model(main) + .workspace(workspace) + .abstractFilesystem(new LocalFilesystem(workspace)) + .subagent(spec) + .buildSubagentEntries(workspace); + + SubagentEntry entry = + entries.stream().filter(e -> "sa".equals(e.name())).findFirst().orElseThrow(); + + HarnessAgent subAgent = (HarnessAgent) entry.factory().create(); + assertSame(sub, subAgent.getDelegate().getModel()); + } + + private static Model stubModel(String assistantText) { + Model model = mock(Model.class); + when(model.getModelName()).thenReturn("stub-model"); + ChatResponse chunk = + new ChatResponse( + "stub-id", + List.of(TextBlock.builder().text(assistantText).build()), + null, + Map.of(), + "stop"); + when(model.stream(anyList(), any(), any())).thenReturn(Flux.just(chunk)); + return model; + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentTest.java new file mode 100644 index 000000000..f3f3ca386 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentTest.java @@ -0,0 +1,244 @@ +/* + * 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; + +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.atLeast; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ToolSchema; +import io.agentscope.core.session.Session; +import io.agentscope.harness.agent.filesystem.LocalFilesystem; +import io.agentscope.harness.agent.filesystem.RemoteFilesystemSpec; +import io.agentscope.harness.agent.hook.SubagentsHook.SubagentEntry; +import io.agentscope.harness.agent.store.InMemoryStore; +import io.agentscope.harness.agent.workspace.WorkspaceConstants; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.ArgumentCaptor; +import reactor.core.publisher.Flux; + +/** + * Tests for {@link HarnessAgent} workspace wiring: {@code AGENTS.md} context and subagent + * discovery ({@code subagents/*.md}). + */ +class HarnessAgentTest { + + @TempDir Path workspace; + + @Test + void workspaceAgentsMd_readableViaWorkspaceManager() throws Exception { + Files.createDirectories(workspace); + String marker = "persona-marker-unique-agents-md-42"; + Files.writeString( + workspace.resolve(WorkspaceConstants.AGENTS_MD), "# Test\n" + marker + "\n"); + + Model model = stubModel("ok"); + HarnessAgent agent = + HarnessAgent.builder() + .name("t") + .model(model) + .workspace(workspace) + .abstractFilesystem(new LocalFilesystem(workspace)) + .build(); + + assertTrue(agent.getWorkspaceManager().readAgentsMd().contains(marker)); + } + + @Test + void workspaceAgentsMd_injectedIntoMessagesSeenByModel() throws Exception { + Files.createDirectories(workspace); + String marker = "injected-via-workspace-context-99"; + Files.writeString(workspace.resolve(WorkspaceConstants.AGENTS_MD), marker); + + Model model = stubModel("assistant-done"); + HarnessAgent agent = + HarnessAgent.builder() + .name("t") + .model(model) + .workspace(workspace) + .abstractFilesystem(new LocalFilesystem(workspace)) + .build(); + + agent.call(userText("hi"), RuntimeContext.builder().sessionId("s1").build()).block(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(model, atLeast(1)).stream(captor.capture(), any(), any()); + String combined = + captor.getAllValues().stream() + .map(HarnessAgentTest::joinAllText) + .filter(s -> s.contains("")) + .findFirst() + .orElse(""); + assertTrue( + combined.contains(""), + "expected workspace hook to wrap AGENTS.md in agents_context"); + assertTrue( + combined.contains(marker), "model should see AGENTS.md body in injected context"); + } + + @Test + void subagentMarkdown_registersIdsAndSubagentTools() throws Exception { + Files.createDirectories(workspace); + Files.writeString(workspace.resolve(WorkspaceConstants.AGENTS_MD), "# workspace\n"); + String specId = "markdown-subagent-id-77"; + Path subagents = workspace.resolve("subagents"); + Files.createDirectories(subagents); + Files.writeString( + subagents.resolve("from-md.md"), + """ + --- + name: %s + description: From subagents/*.md for tests + --- + You only reply OK. + """ + .formatted(specId)); + + Model model = stubModel("done"); + HarnessAgent agent = + HarnessAgent.builder() + .name("main") + .model(model) + .workspace(workspace) + .abstractFilesystem(new LocalFilesystem(workspace)) + .build(); + + List toolNames = + agent.getDelegate().getToolkit().getToolSchemas().stream() + .map(ToolSchema::getName) + .collect(Collectors.toList()); + assertTrue( + toolNames.contains("agent_spawn"), "subagent support should register agent_spawn"); + assertTrue( + toolNames.contains("task_output"), + "subagent async path should register task_output"); + + agent.call(userText("go"), RuntimeContext.builder().sessionId("s2").build()).block(); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(model, atLeast(1)).stream(captor.capture(), any(), any()); + String combined = + captor.getAllValues().stream() + .map(HarnessAgentTest::joinAllText) + .filter(s -> s.contains("## Subagents")) + .findFirst() + .orElse(""); + assertTrue( + combined.contains("## Subagents"), "subagent hook should inject Subagents section"); + assertTrue( + combined.contains("`" + specId + "`"), + "Markdown subagent id should appear in prompt"); + assertTrue( + combined.contains("general-purpose"), + "built-in general-purpose entry should be listed"); + } + + @Test + void subagentsDir_loadsMarkdownSpecs() throws Exception { + Files.createDirectories(workspace); + Files.writeString(workspace.resolve(WorkspaceConstants.AGENTS_MD), "# w\n"); + Path subagents = workspace.resolve("subagents"); + Files.createDirectories(subagents); + String mdId = "md-frontmatter-agent-88"; + Files.writeString( + subagents.resolve("helper.md"), + """ + --- + name: %s + description: Loaded from subagents/*.md + maxIters: 3 + --- + + You are a test subagent from markdown. + """ + .formatted(mdId)); + + List entries = + HarnessAgent.builder() + .model(stubModel("x")) + .workspace(workspace) + .buildSubagentEntries(workspace); + + List names = entries.stream().map(SubagentEntry::name).collect(Collectors.toList()); + assertTrue(names.contains("general-purpose")); + assertTrue( + names.contains(mdId), "subagents/*.md with front matter should produce an entry"); + } + + @Test + void remoteFilesystemSpec_sharesMemoryMdInNonsandboxMode() throws Exception { + Files.createDirectories(workspace); + Files.writeString(workspace.resolve(WorkspaceConstants.AGENTS_MD), "# Test\n"); + InMemoryStore store = new InMemoryStore(); + + HarnessAgent agent = + HarnessAgent.builder() + .name("agent-a") + .model(stubModel("ok")) + .workspace(workspace) + .filesystem(new RemoteFilesystemSpec(store)) + .session(mock(Session.class)) + .build(); + + agent.getWorkspaceManager().writeUtf8WorkspaceRelative("MEMORY.md", "shared-memory"); + + assertTrue( + store.get(List.of("agents", "agent-a", "users", "_default"), "/MEMORY.md") != null); + } + + private static Msg userText(String text) { + return Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text(text).build()) + .build(); + } + + private static String joinAllText(List msgs) { + return msgs.stream().map(Msg::getTextContent).collect(Collectors.joining("\n")); + } + + private static Model stubModel(String assistantText) { + Model model = mock(Model.class); + when(model.getModelName()).thenReturn("stub-model"); + ChatResponse chunk = + new ChatResponse( + "stub-id", + List.of(TextBlock.builder().text(assistantText).build()), + null, + Map.of(), + "stop"); + when(model.stream(anyList(), any(), any())).thenReturn(Flux.just(chunk)); + return model; + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemPersonalAssistantExampleTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemPersonalAssistantExampleTest.java new file mode 100644 index 000000000..85eb3b137 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/LocalFilesystemPersonalAssistantExampleTest.java @@ -0,0 +1,198 @@ +/* + * 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.example; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.Model; +import io.agentscope.harness.agent.HarnessAgent; +import io.agentscope.harness.agent.filesystem.LocalFilesystemWithShell; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import reactor.core.publisher.Flux; + +/** + * Example: Fully local personal-assistant mode using {@link LocalFilesystemWithShell}. + * + *

    Context

    + *

    This mode is ideal for a personal local assistant running on a developer's machine + * or in a single-process environment: + *

      + *
    • All file I/O goes directly to a local workspace directory on disk.
    • + *
    • Shell commands execute in that directory via {@code ProcessBuilder}.
    • + *
    • No sandbox container, no distributed store, no external dependencies.
    • + *
    • State persists naturally as files on disk between calls.
    • + *
    • Changing the {@code userId} or {@code sessionId} in the {@link RuntimeContext} does + * not redirect I/O to a different location — the workspace directory is always + * the same.
    • + *
    + * + *

    Trade-offs

    + *
      + *
    • Pro: Zero infrastructure, instant setup, full local control.
    • + *
    • Con: No isolation between users or sessions, no horizontal scaling.
    • + *
    + * + *

    Configure via {@link HarnessAgent.Builder#abstractFilesystem} with a + * {@link LocalFilesystemWithShell} instance pointing at your desired workspace directory. + */ +class LocalFilesystemPersonalAssistantExampleTest { + + @TempDir Path workspace; + + /** + * Demonstrates that files written to the workspace during one call persist to disk and are + * readable in subsequent calls — the most fundamental characteristic of the local mode. + */ + @Test + void localFilesystem_filesPersistAcrossCalls() throws Exception { + Files.createDirectories(workspace); + + // Build the agent with a LocalFilesystemWithShell backend. + // No distributed store, no sandbox — all operations go straight to disk. + HarnessAgent agent = + HarnessAgent.builder() + .name("my-local-assistant") + .model(stubModel("done")) + .workspace(workspace.toAbsolutePath().normalize().toString()) + .abstractFilesystem(new LocalFilesystemWithShell(workspace)) + .build(); + + // Call 1: write a note to MEMORY.md through the workspace manager + agent.call(userMsg("first call"), ctx("session-1", "alice")).block(); + agent.getWorkspaceManager().writeUtf8WorkspaceRelative("MEMORY.md", "# Notes\n- item 1"); + + // The file exists on disk after call 1 + Path memoryFile = workspace.resolve("MEMORY.md"); + assertTrue(Files.isRegularFile(memoryFile), "MEMORY.md should exist on disk after call 1"); + String content = Files.readString(memoryFile, StandardCharsets.UTF_8); + assertTrue(content.contains("item 1"), "MEMORY.md content should be persisted on disk"); + + // Call 2: same workspace, different session — file is still there + agent.call(userMsg("second call"), ctx("session-2", "alice")).block(); + assertTrue( + Files.isRegularFile(memoryFile), "MEMORY.md should still exist on disk in call 2"); + assertEquals( + content, + Files.readString(memoryFile, StandardCharsets.UTF_8), + "MEMORY.md content should be unchanged after call 2"); + } + + /** + * Demonstrates that changing {@code userId} or {@code sessionId} does NOT redirect I/O to a + * different location in local mode. + * + *

    This is the key distinction from sandbox/remote modes: in local mode the workspace + * directory is fixed, so all users and sessions share the same disk location. + */ + @Test + void localFilesystem_workspaceIsNotPartitionedByUserOrSession() throws Exception { + Files.createDirectories(workspace); + + HarnessAgent agent = + HarnessAgent.builder() + .name("my-local-assistant") + .model(stubModel("done")) + .workspace(workspace.toAbsolutePath().normalize().toString()) + .abstractFilesystem(new LocalFilesystemWithShell(workspace)) + .build(); + + // Alice writes during her session + agent.call(userMsg("alice here"), ctx("session-alice", "alice")).block(); + agent.getWorkspaceManager().writeUtf8WorkspaceRelative("shared.txt", "alice was here"); + + // Bob calls with a different userId — still reads the same workspace + agent.call(userMsg("bob here"), ctx("session-bob", "bob")).block(); + Path sharedFile = workspace.resolve("shared.txt"); + assertTrue( + Files.isRegularFile(sharedFile), + "shared.txt written by alice should be visible in the same workspace, " + + "regardless of userId or sessionId"); + } + + /** + * Demonstrates that the underlying workspace directory is a plain local filesystem path — + * you can read and write files with standard Java I/O alongside the agent. + */ + @Test + void localFilesystem_directDiskAccessFromHostProcess() throws Exception { + Files.createDirectories(workspace); + + HarnessAgent agent = + HarnessAgent.builder() + .name("my-local-assistant") + .model(stubModel("done")) + .workspace(workspace.toAbsolutePath().normalize().toString()) + .abstractFilesystem(new LocalFilesystemWithShell(workspace)) + .build(); + + // Write a file from the host process (simulating a user placing a document in the + // workspace) + Path doc = workspace.resolve("document.txt"); + Files.writeString(doc, "Host-written document content"); + + // The agent can see the file through its workspace manager + agent.call(userMsg("check document"), ctx("s1", "user")).block(); + String read = agent.getWorkspaceManager().readManagedWorkspaceFileUtf8("document.txt"); + assertNotNull(read, "agent should be able to read files written directly to the workspace"); + assertTrue(read.contains("Host-written"), "agent should see the host-written content"); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static RuntimeContext ctx(String sessionId, String userId) { + return RuntimeContext.builder().sessionId(sessionId).userId(userId).build(); + } + + private static Msg userMsg(String text) { + return Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text(text).build()) + .build(); + } + + private static Model stubModel(String text) { + Model model = mock(Model.class); + when(model.getModelName()).thenReturn("stub-model"); + ChatResponse chunk = + new ChatResponse( + "stub-id", + List.of(TextBlock.builder().text(text).build()), + null, + Map.of(), + "stop"); + when(model.stream(anyList(), any(), any())).thenReturn(Flux.just(chunk)); + return model; + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/RemoteFilesystemIsolationScopeExampleTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/RemoteFilesystemIsolationScopeExampleTest.java new file mode 100644 index 000000000..207171ab1 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/RemoteFilesystemIsolationScopeExampleTest.java @@ -0,0 +1,289 @@ +/* + * 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.example; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.Model; +import io.agentscope.core.session.Session; +import io.agentscope.harness.agent.HarnessAgent; +import io.agentscope.harness.agent.IsolationScope; +import io.agentscope.harness.agent.filesystem.RemoteFilesystemSpec; +import io.agentscope.harness.agent.store.InMemoryStore; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import reactor.core.publisher.Flux; + +/** + * Example: Distributed shared remote filesystem mode with different {@link IsolationScope} levels. + * + *

    Context

    + *

    In remote mode, selected paths (such as {@code MEMORY.md}, {@code memory/}) are routed to a + * shared key-value store ({@link InMemoryStore} here; typically Redis or another distributed + * store in production). The {@code IsolationScope} controls the store namespace prefix and + * therefore which agent calls can read each other's stored data. + * + *

    Three scopes are demonstrated: + *

      + *
    • {@link IsolationScope#SESSION} – each session gets its own namespace. Calls within the + * same session see the same data; calls from a different session see an empty namespace.
    • + *
    • {@link IsolationScope#USER} – all sessions of the same user share one namespace. A + * different user gets an isolated namespace.
    • + *
    • {@link IsolationScope#AGENT} – all users and sessions share one namespace per agent. + * Data written by any caller is visible to all other callers of the same agent.
    • + *
    + * + *

    Assertions directly inspect the {@link InMemoryStore} using the namespace keys generated by + * the scopes: + *

      + *
    • {@code SESSION}: {@code agents//sessions//MEMORY.md}
    • + *
    • {@code USER}: {@code agents//users//MEMORY.md}
    • + *
    • {@code AGENT}: {@code agents//shared/MEMORY.md}
    • + *
    + * + *

    This test does not call {@link HarnessAgent#call} for all cases — it uses + * {@link io.agentscope.harness.agent.workspace.WorkspaceManager#writeUtf8WorkspaceRelative} + * directly where possible to keep the example focused on namespace routing rather than agent + * conversation mechanics. + */ +class RemoteFilesystemIsolationScopeExampleTest { + + @TempDir Path workspace; + + // ------------------------------------------------------------------------- + // Scenario A: SESSION scope + // ------------------------------------------------------------------------- + + /** + * SESSION scope — writes from session-1 are NOT visible to session-2. + * + *

    The store namespace is {@code agents//sessions/}, so each session + * has a fully independent MEMORY.md. + */ + @Test + void sessionScope_differentSessions_isolatedNamespaces() throws Exception { + Files.createDirectories(workspace); + InMemoryStore store = new InMemoryStore(); + + HarnessAgent agent = + HarnessAgent.builder() + .name("assistant") + .model(stubModel("done")) + .workspace(workspace.toAbsolutePath().normalize().toString()) + .filesystem( + new RemoteFilesystemSpec(store) + .isolationScope(IsolationScope.SESSION)) + .session(mock(Session.class)) + .build(); + + // Call as session-1 and write MEMORY.md + agent.call(userMsg("from session-1"), ctx("session-1", "alice")).block(); + agent.getWorkspaceManager().writeUtf8WorkspaceRelative("MEMORY.md", "session-1 notes"); + + // Verify the data landed in the session-1 namespace + assertNotNull( + store.get(List.of("agents", "assistant", "sessions", "session-1"), "/MEMORY.md"), + "MEMORY.md should be stored under the session-1 namespace"); + + // Verify session-2 namespace is empty (isolated) + assertNull( + store.get(List.of("agents", "assistant", "sessions", "session-2"), "/MEMORY.md"), + "session-2 namespace should be empty"); + } + + /** + * SESSION scope — writes from the same session are shared across calls. + */ + @Test + void sessionScope_sameSession_sharedNamespace() throws Exception { + Files.createDirectories(workspace); + InMemoryStore store = new InMemoryStore(); + + HarnessAgent agent = + HarnessAgent.builder() + .name("assistant") + .model(stubModel("done")) + .workspace(workspace.toAbsolutePath().normalize().toString()) + .filesystem( + new RemoteFilesystemSpec(store) + .isolationScope(IsolationScope.SESSION)) + .session(mock(Session.class)) + .build(); + + // First call writes MEMORY.md under session-1 + agent.call(userMsg("call 1"), ctx("session-1", "alice")).block(); + agent.getWorkspaceManager().writeUtf8WorkspaceRelative("MEMORY.md", "shared memory"); + + // Second call with same session can read it + agent.call(userMsg("call 2"), ctx("session-1", "alice")).block(); + + assertNotNull( + store.get(List.of("agents", "assistant", "sessions", "session-1"), "/MEMORY.md"), + "MEMORY.md should persist in the session-1 namespace across calls"); + } + + // ------------------------------------------------------------------------- + // Scenario B: USER scope + // ------------------------------------------------------------------------- + + /** + * USER scope — different sessions of the same user share a single namespace. + * + *

    The namespace is {@code agents//users/}, so data written by session-A + * is immediately visible to session-B of the same user. + */ + @Test + void userScope_sameUser_sharedAcrossSessions() throws Exception { + Files.createDirectories(workspace); + InMemoryStore store = new InMemoryStore(); + + HarnessAgent agent = + HarnessAgent.builder() + .name("assistant") + .model(stubModel("done")) + .workspace(workspace.toAbsolutePath().normalize().toString()) + .filesystem( + new RemoteFilesystemSpec(store).isolationScope(IsolationScope.USER)) + .session(mock(Session.class)) + .build(); + + // Call as alice / session-a and write MEMORY.md + agent.call(userMsg("hi from session-a"), ctx("session-a", "alice")).block(); + agent.getWorkspaceManager().writeUtf8WorkspaceRelative("MEMORY.md", "alice's memory"); + + // The entry lands in the user-scoped namespace + assertNotNull( + store.get(List.of("agents", "assistant", "users", "alice"), "/MEMORY.md"), + "MEMORY.md should be stored under alice's user namespace"); + + // Call as alice / session-b — sees the same store namespace + agent.call(userMsg("hi from session-b"), ctx("session-b", "alice")).block(); + assertNotNull( + store.get(List.of("agents", "assistant", "users", "alice"), "/MEMORY.md"), + "alice's MEMORY.md should still be accessible from session-b"); + } + + /** + * USER scope — different users have isolated namespaces. + */ + @Test + void userScope_differentUsers_isolatedNamespaces() throws Exception { + Files.createDirectories(workspace); + InMemoryStore store = new InMemoryStore(); + + HarnessAgent agent = + HarnessAgent.builder() + .name("assistant") + .model(stubModel("done")) + .workspace(workspace.toAbsolutePath().normalize().toString()) + .filesystem( + new RemoteFilesystemSpec(store).isolationScope(IsolationScope.USER)) + .session(mock(Session.class)) + .build(); + + agent.call(userMsg("alice writes"), ctx("s1", "alice")).block(); + agent.getWorkspaceManager().writeUtf8WorkspaceRelative("MEMORY.md", "alice's data"); + + // Bob's namespace should be empty + assertNull( + store.get(List.of("agents", "assistant", "users", "bob"), "/MEMORY.md"), + "bob's namespace should be isolated from alice's"); + } + + // ------------------------------------------------------------------------- + // Scenario C: AGENT scope + // ------------------------------------------------------------------------- + + /** + * AGENT scope — all users and sessions share one namespace per agent. + * + *

    The namespace is {@code agents//shared}, so any user's write is visible + * to any other user of the same agent. + */ + @Test + void agentScope_allCallersShareOneNamespace() throws Exception { + Files.createDirectories(workspace); + InMemoryStore store = new InMemoryStore(); + + HarnessAgent agent = + HarnessAgent.builder() + .name("shared-assistant") + .model(stubModel("done")) + .workspace(workspace.toAbsolutePath().normalize().toString()) + .filesystem( + new RemoteFilesystemSpec(store) + .isolationScope(IsolationScope.AGENT)) + .session(mock(Session.class)) + .build(); + + // Alice writes + agent.call(userMsg("alice"), ctx("s1", "alice")).block(); + agent.getWorkspaceManager().writeUtf8WorkspaceRelative("MEMORY.md", "shared knowledge"); + + assertNotNull( + store.get(List.of("agents", "shared-assistant", "shared"), "/MEMORY.md"), + "MEMORY.md should be in the agent-scoped shared namespace"); + + // Bob's call also reads from the same shared namespace + agent.call(userMsg("bob"), ctx("s2", "bob")).block(); + assertNotNull( + store.get(List.of("agents", "shared-assistant", "shared"), "/MEMORY.md"), + "shared namespace should still contain the data after bob's call"); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static RuntimeContext ctx(String sessionId, String userId) { + return RuntimeContext.builder().sessionId(sessionId).userId(userId).build(); + } + + private static Msg userMsg(String text) { + return Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text(text).build()) + .build(); + } + + private static Model stubModel(String text) { + Model model = mock(Model.class); + when(model.getModelName()).thenReturn("stub-model"); + ChatResponse chunk = + new ChatResponse( + "stub-id", + List.of(TextBlock.builder().text(text).build()), + null, + Map.of(), + "stop"); + when(model.stream(anyList(), any(), any())).thenReturn(Flux.just(chunk)); + return model; + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/SandboxFilesystemIsolationScopeExampleTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/SandboxFilesystemIsolationScopeExampleTest.java new file mode 100644 index 000000000..f63d5bd95 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/SandboxFilesystemIsolationScopeExampleTest.java @@ -0,0 +1,286 @@ +/* + * 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.example; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.message.Msg; +import io.agentscope.core.message.MsgRole; +import io.agentscope.core.message.TextBlock; +import io.agentscope.core.model.ChatResponse; +import io.agentscope.core.model.Model; +import io.agentscope.harness.agent.HarnessAgent; +import io.agentscope.harness.agent.IsolationScope; +import io.agentscope.harness.agent.example.support.InMemorySandboxClient; +import io.agentscope.harness.agent.example.support.InMemorySandboxFilesystemSpec; +import io.agentscope.harness.agent.sandbox.SandboxDistributedOptions; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import reactor.core.publisher.Flux; + +/** + * Example: Sandbox filesystem mode with different {@link IsolationScope} levels. + * + *

    Context

    + *

    In sandbox mode, each agent call acquires a sandbox session (e.g. a Docker container or, + * in this test, a local temp directory). The {@code IsolationScope} controls which calls + * resume the same sandbox (sharing accumulated state) versus which ones + * create a fresh sandbox. + * + *

    Three scopes are demonstrated: + *

      + *
    • {@link IsolationScope#SESSION} – calls with the same session ID resume the same sandbox. + * Calls from a different session get a brand-new sandbox.
    • + *
    • {@link IsolationScope#USER} – calls from the same user (any session) resume the same + * sandbox. Calls from a different user get a fresh sandbox.
    • + *
    • {@link IsolationScope#AGENT} – all calls share one sandbox regardless of user or + * session.
    • + *
    + * + *

    This test uses {@link InMemorySandboxClient} (no Docker required) and a Mockito-stubbed + * {@link Model} that immediately returns a terminal text response so the ReAct loop exits in one + * step. The assertions count {@link InMemorySandboxClient#getCreateCount()} and + * {@link InMemorySandboxClient#getResumeCount()} to verify isolation behaviour. + */ +class SandboxFilesystemIsolationScopeExampleTest { + + @TempDir Path workspace; + + // ------------------------------------------------------------------------- + // Scenario A: SESSION scope + // ------------------------------------------------------------------------- + + /** + * SESSION scope — same session ID → sandbox is resumed on the second call. + * + *

    Call 1: no persisted state → {@code create} is called (creates fresh sandbox). + * Call 2: state was persisted for "session-1" → {@code resume} is called (same sandbox). + */ + @Test + void sessionScope_sameSession_resumesSandbox() throws Exception { + Files.createDirectories(workspace); + + InMemorySandboxFilesystemSpec spec = new InMemorySandboxFilesystemSpec(); + spec.isolationScope(IsolationScope.SESSION); + InMemorySandboxClient client = spec.getClient(); + + HarnessAgent agent = + HarnessAgent.builder() + .name("assistant") + .model(stubModel("done")) + .workspace(workspace.toAbsolutePath().normalize().toString()) + .filesystem(spec) + .sandboxDistributed( + SandboxDistributedOptions.builder() + .requireDistributed(false) + .build()) + .build(); + + // First call — no persisted state → create + agent.call(userMsg("hello"), ctx("session-1", null)).block(); + assertEquals(1, client.getCreateCount(), "first call should create a fresh sandbox"); + assertEquals(0, client.getResumeCount()); + + // Second call — same session → resume + agent.call(userMsg("hello again"), ctx("session-1", null)).block(); + assertEquals(1, client.getCreateCount(), "second call should NOT create a new sandbox"); + assertEquals(1, client.getResumeCount(), "second call should resume the existing sandbox"); + } + + /** + * SESSION scope — different session ID → each call creates a fresh sandbox. + */ + @Test + void sessionScope_differentSession_createsFreshSandbox() throws Exception { + Files.createDirectories(workspace); + + InMemorySandboxFilesystemSpec spec = new InMemorySandboxFilesystemSpec(); + spec.isolationScope(IsolationScope.SESSION); + InMemorySandboxClient client = spec.getClient(); + + HarnessAgent agent = + HarnessAgent.builder() + .name("assistant") + .model(stubModel("done")) + .workspace(workspace.toAbsolutePath().normalize().toString()) + .filesystem(spec) + .sandboxDistributed( + SandboxDistributedOptions.builder() + .requireDistributed(false) + .build()) + .build(); + + agent.call(userMsg("call from session-1"), ctx("session-2-1", "alice")).block(); + agent.call(userMsg("call from session-2"), ctx("session-2-2", "alice")).block(); + + assertEquals( + 2, client.getCreateCount(), "each distinct session should create its own sandbox"); + assertEquals(0, client.getResumeCount()); + } + + // ------------------------------------------------------------------------- + // Scenario B: USER scope + // ------------------------------------------------------------------------- + + /** + * USER scope — same user, different session IDs → both calls share the same sandbox. + * + *

    Call 1: no persisted state for user "alice" → {@code create}. + * Call 2: state exists for "alice" (different sessionId) → {@code resume}. + */ + @Test + void userScope_sameUser_differentSessions_resumesSandbox() throws Exception { + Files.createDirectories(workspace); + + InMemorySandboxFilesystemSpec spec = new InMemorySandboxFilesystemSpec(); + spec.isolationScope(IsolationScope.USER); + InMemorySandboxClient client = spec.getClient(); + + HarnessAgent agent = + HarnessAgent.builder() + .name("assistant") + .model(stubModel("done")) + .workspace(workspace.toAbsolutePath().normalize().toString()) + .filesystem(spec) + .sandboxDistributed( + SandboxDistributedOptions.builder() + .requireDistributed(false) + .build()) + .build(); + + agent.call(userMsg("session A"), ctx("session-a", "alice")).block(); + assertEquals(1, client.getCreateCount()); + assertEquals(0, client.getResumeCount()); + + // Different session, same user → should resume Alice's sandbox + agent.call(userMsg("session B"), ctx("session-b", "alice")).block(); + assertEquals(1, client.getCreateCount(), "same user → should NOT create a new sandbox"); + assertEquals(1, client.getResumeCount(), "same user → should resume existing sandbox"); + } + + /** + * USER scope — different users → each user gets an independent sandbox. + */ + @Test + void userScope_differentUsers_createsFreshSandbox() throws Exception { + Files.createDirectories(workspace); + + InMemorySandboxFilesystemSpec spec = new InMemorySandboxFilesystemSpec(); + spec.isolationScope(IsolationScope.USER); + InMemorySandboxClient client = spec.getClient(); + + HarnessAgent agent = + HarnessAgent.builder() + .name("assistant") + .model(stubModel("done")) + .workspace(workspace.toAbsolutePath().normalize().toString()) + .filesystem(spec) + .sandboxDistributed( + SandboxDistributedOptions.builder() + .requireDistributed(false) + .build()) + .build(); + + agent.call(userMsg("hi from alice2"), ctx("s1", "alice2")).block(); + agent.call(userMsg("hi from bob2"), ctx("s2", "bob2")).block(); + + assertEquals(2, client.getCreateCount(), "each user should get their own fresh sandbox"); + assertEquals(0, client.getResumeCount()); + } + + // ------------------------------------------------------------------------- + // Scenario C: AGENT scope + // ------------------------------------------------------------------------- + + /** + * AGENT scope — all calls share a single sandbox regardless of user or session. + * + *

    Call 1: no state → {@code create}. + * Call 2: state exists (agentId key) → {@code resume} — even with a different user and session. + */ + @Test + void agentScope_allCallsShareOneSandbox() throws Exception { + Files.createDirectories(workspace); + + InMemorySandboxFilesystemSpec spec = new InMemorySandboxFilesystemSpec(); + spec.isolationScope(IsolationScope.AGENT); + InMemorySandboxClient client = spec.getClient(); + + HarnessAgent agent = + HarnessAgent.builder() + .name("shared-assistant") + .model(stubModel("done")) + .workspace(workspace.toAbsolutePath().normalize().toString()) + .filesystem(spec) + .sandboxDistributed( + SandboxDistributedOptions.builder() + .requireDistributed(false) + .build()) + .build(); + + // Different users, different sessions — all share one AGENT-scoped sandbox + agent.call(userMsg("alice says hi"), ctx("s1", "alice")).block(); + assertEquals(1, client.getCreateCount()); + + agent.call(userMsg("bob says hi"), ctx("s2", "bob")).block(); + assertEquals(1, client.getCreateCount(), "second call should NOT create a new sandbox"); + assertEquals( + 1, client.getResumeCount(), "second call should resume the shared agent sandbox"); + + agent.call(userMsg("charlie says hi"), ctx("s3", "charlie")).block(); + assertEquals(1, client.getCreateCount()); + assertEquals( + 2, client.getResumeCount(), "third call should also resume the shared sandbox"); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static RuntimeContext ctx(String sessionId, String userId) { + return RuntimeContext.builder().sessionId(sessionId).userId(userId).build(); + } + + private static Msg userMsg(String text) { + return Msg.builder() + .role(MsgRole.USER) + .content(TextBlock.builder().text(text).build()) + .build(); + } + + private static Model stubModel(String text) { + Model model = mock(Model.class); + when(model.getModelName()).thenReturn("stub-model"); + ChatResponse chunk = + new ChatResponse( + "stub-id", + List.of(TextBlock.builder().text(text).build()), + null, + Map.of(), + "stop"); + when(model.stream(anyList(), any(), any())).thenReturn(Flux.just(chunk)); + return model; + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/support/InMemorySandbox.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/support/InMemorySandbox.java new file mode 100644 index 000000000..f42059a9c --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/support/InMemorySandbox.java @@ -0,0 +1,116 @@ +/* + * 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.example.support; + +import io.agentscope.harness.agent.sandbox.ExecResult; +import io.agentscope.harness.agent.sandbox.Sandbox; +import io.agentscope.harness.agent.sandbox.SandboxState; +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * Test-only {@link Sandbox} that uses a local temp directory as the workspace. + */ +public class InMemorySandbox implements Sandbox { + + private final InMemorySandboxState state; + private final Path workspaceDir; + private final AtomicBoolean running = new AtomicBoolean(false); + private final int defaultTimeoutSeconds; + + public InMemorySandbox(InMemorySandboxState state, int defaultTimeoutSeconds) { + this.state = state; + this.workspaceDir = Path.of(state.getWorkspaceRoot()); + this.defaultTimeoutSeconds = defaultTimeoutSeconds; + } + + @Override + public void start() throws Exception { + if (!Files.exists(workspaceDir)) { + Files.createDirectories(workspaceDir); + } + state.setWorkspaceRootReady(true); + running.set(true); + } + + @Override + public void stop() throws Exception { + state.setWorkspaceRootReady(true); + running.set(false); + } + + @Override + public void shutdown() throws Exception { + // Leave workspace dir in place for resume in tests + } + + @Override + public void close() throws Exception { + try { + stop(); + } catch (Exception e) { + // best-effort + } + shutdown(); + } + + @Override + public boolean isRunning() { + return running.get(); + } + + @Override + public SandboxState getState() { + return state; + } + + @Override + public ExecResult exec(String command, Integer timeoutSeconds) throws Exception { + int timeout = timeoutSeconds != null ? timeoutSeconds : defaultTimeoutSeconds; + ProcessBuilder pb = new ProcessBuilder("sh", "-c", command); + pb.directory(workspaceDir.toFile()); + pb.redirectErrorStream(false); + Process process = pb.start(); + + boolean finished = process.waitFor(timeout, TimeUnit.SECONDS); + if (!finished) { + process.destroyForcibly(); + return new ExecResult(124, "", "Command timed out after " + timeout + "s", false); + } + + String stdout = new String(process.getInputStream().readAllBytes()); + String stderr = new String(process.getErrorStream().readAllBytes()); + return new ExecResult(process.exitValue(), stdout, stderr, false); + } + + @Override + public InputStream persistWorkspace() throws Exception { + return new ByteArrayInputStream(new byte[1024]); + } + + @Override + public void hydrateWorkspace(InputStream archive) throws Exception { + // no-op + } + + public Path getWorkspaceDir() { + return workspaceDir; + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/support/InMemorySandboxClient.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/support/InMemorySandboxClient.java new file mode 100644 index 000000000..c25ffaa46 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/support/InMemorySandboxClient.java @@ -0,0 +1,122 @@ +/* + * 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.example.support; + +import com.fasterxml.jackson.databind.ObjectMapper; +import io.agentscope.harness.agent.sandbox.Sandbox; +import io.agentscope.harness.agent.sandbox.SandboxClient; +import io.agentscope.harness.agent.sandbox.SandboxClientOptions; +import io.agentscope.harness.agent.sandbox.SandboxState; +import io.agentscope.harness.agent.sandbox.WorkspaceSpec; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.UUID; +import java.util.concurrent.atomic.AtomicInteger; + +/** Test-only {@link SandboxClient} that allocates local temp directories as sandboxes. */ +public class InMemorySandboxClient implements SandboxClient { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + private static final int DEFAULT_TIMEOUT_SECONDS = 30; + + private final AtomicInteger createCount = new AtomicInteger(0); + private final AtomicInteger resumeCount = new AtomicInteger(0); + private final Path baseDir; + + public InMemorySandboxClient() { + try { + this.baseDir = Files.createTempDirectory("agentscope-inmemory-sandbox-"); + } catch (IOException e) { + throw new UncheckedIOException( + "Failed to create base temp dir for InMemorySandboxClient", e); + } + } + + @Override + public Sandbox create( + WorkspaceSpec workspaceSpec, + SandboxSnapshotSpec snapshotSpec, + SandboxClientOptions options) { + createCount.incrementAndGet(); + String sessionId = UUID.randomUUID().toString(); + Path workspaceDir = baseDir.resolve(sessionId); + try { + Files.createDirectories(workspaceDir); + } catch (IOException e) { + throw new UncheckedIOException("Failed to create workspace dir", e); + } + + InMemorySandboxState state = new InMemorySandboxState(sessionId, workspaceDir.toString()); + WorkspaceSpec spec = workspaceSpec != null ? workspaceSpec.copy() : new WorkspaceSpec(); + spec.setRoot(workspaceDir.toString()); + state.setWorkspaceSpec(spec); + + return new InMemorySandbox(state, DEFAULT_TIMEOUT_SECONDS); + } + + @Override + public Sandbox resume(SandboxState sessionState) { + resumeCount.incrementAndGet(); + InMemorySandboxState state = (InMemorySandboxState) sessionState; + return new InMemorySandbox(state, DEFAULT_TIMEOUT_SECONDS); + } + + @Override + public void delete(Sandbox session) { + // no-op + } + + @Override + public String serializeState(SandboxState state) { + try { + InMemorySandboxState s = (InMemorySandboxState) state; + return MAPPER.writeValueAsString(new StateDto(s.getSessionId(), s.getWorkspaceRoot())); + } catch (IOException e) { + throw new UncheckedIOException("Failed to serialize sandbox state", e); + } + } + + @Override + public SandboxState deserializeState(String json) { + try { + StateDto dto = MAPPER.readValue(json, StateDto.class); + InMemorySandboxState state = + new InMemorySandboxState(dto.sessionId(), dto.workspaceRoot()); + state.setWorkspaceRootReady(true); + return state; + } catch (IOException e) { + throw new UncheckedIOException("Failed to deserialize sandbox state", e); + } + } + + public int getCreateCount() { + return createCount.get(); + } + + public int getResumeCount() { + return resumeCount.get(); + } + + public void resetCounts() { + createCount.set(0); + resumeCount.set(0); + } + + record StateDto(String sessionId, String workspaceRoot) {} +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/support/InMemorySandboxFilesystemSpec.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/support/InMemorySandboxFilesystemSpec.java new file mode 100644 index 000000000..2cb842dd3 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/support/InMemorySandboxFilesystemSpec.java @@ -0,0 +1,81 @@ +/* + * 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.example.support; + +import io.agentscope.harness.agent.sandbox.SandboxClient; +import io.agentscope.harness.agent.sandbox.SandboxClientOptions; +import io.agentscope.harness.agent.sandbox.WorkspaceSpec; +import io.agentscope.harness.agent.sandbox.filesystem.SandboxFilesystemSpec; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; + +/** + * Test-only {@link SandboxFilesystemSpec} backed by an {@link InMemorySandboxClient}. + * + *

    Uses a local temp directory as the sandbox workspace. No Docker or external dependencies + * are required. The underlying {@link InMemorySandboxClient} exposes create/resume counters + * so isolation-scope tests can verify whether a sandbox was freshly created or resumed. + */ +public class InMemorySandboxFilesystemSpec extends SandboxFilesystemSpec { + + private final InMemorySandboxClient client; + + /** + * Creates a spec backed by the given in-memory sandbox client. + * + * @param client the in-memory client to use for session create/resume + */ + public InMemorySandboxFilesystemSpec(InMemorySandboxClient client) { + this.client = client; + } + + /** + * Creates a spec with a new default {@link InMemorySandboxClient}. + */ + public InMemorySandboxFilesystemSpec() { + this(new InMemorySandboxClient()); + } + + /** + * Returns the underlying {@link InMemorySandboxClient}. + * + * @return the client + */ + public InMemorySandboxClient getClient() { + return client; + } + + @Override + protected SandboxClient createClient() { + return client; + } + + @Override + protected SandboxClientOptions clientOptions() { + return null; + } + + @Override + protected SandboxSnapshotSpec snapshotSpec() { + return null; + } + + @Override + protected WorkspaceSpec workspaceSpec() { + WorkspaceSpec s = new WorkspaceSpec(); + s.setRoot("/workspace"); + return s; + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/support/InMemorySandboxState.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/support/InMemorySandboxState.java new file mode 100644 index 000000000..81564f13e --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/example/support/InMemorySandboxState.java @@ -0,0 +1,41 @@ +/* + * 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.example.support; + +import io.agentscope.harness.agent.sandbox.SandboxState; + +/** + * Test-only {@link SandboxState} for {@link InMemorySandboxClient}. + */ +public class InMemorySandboxState extends SandboxState { + + private String workspaceRoot; + + public InMemorySandboxState() {} + + public InMemorySandboxState(String sessionId, String workspaceRoot) { + setSessionId(sessionId); + this.workspaceRoot = workspaceRoot; + } + + public String getWorkspaceRoot() { + return workspaceRoot; + } + + public void setWorkspaceRoot(String workspaceRoot) { + this.workspaceRoot = workspaceRoot; + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/FilesystemDeleteMoveExistsTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/FilesystemDeleteMoveExistsTest.java new file mode 100644 index 000000000..60a9ef6ce --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/FilesystemDeleteMoveExistsTest.java @@ -0,0 +1,221 @@ +/* + * 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.filesystem; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.filesystem.model.WriteResult; +import io.agentscope.harness.agent.store.InMemoryStore; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Verifies delete / move / exists on all {@link AbstractFilesystem} implementations. + * + *

    LocalFilesystem is tested in non-virtual mode; paths are workspace-relative (no leading '/'). + * RemoteFilesystem uses leading-slash keys matching its internal convention. + */ +class FilesystemDeleteMoveExistsTest { + + private static final RuntimeContext RT = RuntimeContext.empty(); + + // ================================================================ + // LocalFilesystem — non-virtual mode, relative paths (no leading '/') + // ================================================================ + + @Test + void local_exists_true(@TempDir Path tmp) throws Exception { + LocalFilesystem fs = new LocalFilesystem(tmp); + Files.writeString(tmp.resolve("file.txt"), "hello"); + + assertTrue(fs.exists(RT, "file.txt")); + } + + @Test + void local_exists_false(@TempDir Path tmp) { + LocalFilesystem fs = new LocalFilesystem(tmp); + assertFalse(fs.exists(RT, "nonexistent.txt")); + } + + @Test + void local_delete_file(@TempDir Path tmp) throws Exception { + LocalFilesystem fs = new LocalFilesystem(tmp); + Path f = Files.writeString(tmp.resolve("del.txt"), "data"); + + WriteResult result = fs.delete(RT, "del.txt"); + assertTrue(result.isSuccess()); + assertFalse(Files.exists(f)); + } + + @Test + void local_delete_idempotent(@TempDir Path tmp) { + LocalFilesystem fs = new LocalFilesystem(tmp); + WriteResult result = fs.delete(RT, "ghost.txt"); + assertTrue(result.isSuccess(), "deleting nonexistent should succeed (idempotent)"); + } + + @Test + void local_delete_directory_recursive(@TempDir Path tmp) throws Exception { + LocalFilesystem fs = new LocalFilesystem(tmp); + Path dir = tmp.resolve("subdir"); + Files.createDirectories(dir); + Files.writeString(dir.resolve("a.txt"), "a"); + Files.writeString(dir.resolve("b.txt"), "b"); + + WriteResult result = fs.delete(RT, "subdir"); + assertTrue(result.isSuccess()); + assertFalse(Files.exists(dir)); + } + + @Test + void local_move_file(@TempDir Path tmp) throws Exception { + LocalFilesystem fs = new LocalFilesystem(tmp); + Files.writeString(tmp.resolve("src.txt"), "content"); + + WriteResult result = fs.move(RT, "src.txt", "dst.txt"); + assertTrue(result.isSuccess()); + assertFalse(Files.exists(tmp.resolve("src.txt"))); + assertTrue(Files.exists(tmp.resolve("dst.txt"))); + } + + @Test + void local_move_missingSource(@TempDir Path tmp) { + LocalFilesystem fs = new LocalFilesystem(tmp); + WriteResult result = fs.move(RT, "missing.txt", "dst.txt"); + assertFalse(result.isSuccess()); + } + + // ================================================================ + // RemoteFilesystem — keys follow leading-slash convention + // ================================================================ + + private static InMemoryStore storeWith(String path, String content) { + InMemoryStore s = new InMemoryStore(); + s.put(List.of("ns"), path, Map.of("content", content, "encoding", "utf-8")); + return s; + } + + @Test + void store_exists_true() { + InMemoryStore s = storeWith("/file.txt", "hello"); + RemoteFilesystem fs = new RemoteFilesystem(s, List.of("ns")); + + assertTrue(fs.exists(RT, "/file.txt")); + } + + @Test + void store_exists_false() { + RemoteFilesystem fs = new RemoteFilesystem(new InMemoryStore(), List.of("ns")); + assertFalse(fs.exists(RT, "/nope.txt")); + } + + @Test + void store_delete_file() { + InMemoryStore s = storeWith("/file.txt", "hello"); + RemoteFilesystem fs = new RemoteFilesystem(s, List.of("ns")); + + WriteResult result = fs.delete(RT, "/file.txt"); + assertTrue(result.isSuccess()); + assertNull(s.get(List.of("ns"), "/file.txt")); + } + + @Test + void store_delete_idempotent() { + RemoteFilesystem fs = new RemoteFilesystem(new InMemoryStore(), List.of("ns")); + WriteResult result = fs.delete(RT, "/ghost.txt"); + assertTrue(result.isSuccess()); + } + + @Test + void store_move_file() { + InMemoryStore s = storeWith("/src.txt", "data"); + RemoteFilesystem fs = new RemoteFilesystem(s, List.of("ns")); + + WriteResult result = fs.move(RT, "/src.txt", "/dst.txt"); + assertTrue(result.isSuccess()); + assertNull(s.get(List.of("ns"), "/src.txt")); + assertNotNull(s.get(List.of("ns"), "/dst.txt")); + } + + @Test + void store_move_missingSource() { + RemoteFilesystem fs = new RemoteFilesystem(new InMemoryStore(), List.of("ns")); + WriteResult result = fs.move(RT, "/missing.txt", "/dst.txt"); + assertFalse(result.isSuccess()); + } + + // ================================================================ + // CompositeFilesystem — routed operations + // ================================================================ + + @Test + void composite_exists_routedToStore(@TempDir Path tmp) { + InMemoryStore s = storeWith("/MEMORY.md", "mem"); + RemoteFilesystem storeFsys = new RemoteFilesystem(s, List.of("ns")); + LocalFilesystem local = new LocalFilesystem(tmp); + + CompositeFilesystem fs = new CompositeFilesystem(local, Map.of("MEMORY.md", storeFsys)); + + assertTrue(fs.exists(RT, "MEMORY.md")); + assertFalse(fs.exists(RT, "notExist.txt")); + } + + @Test + void composite_delete_routedToStore(@TempDir Path tmp) { + InMemoryStore s = storeWith("/MEMORY.md", "mem"); + RemoteFilesystem storeFsys = new RemoteFilesystem(s, List.of("ns")); + LocalFilesystem local = new LocalFilesystem(tmp); + + CompositeFilesystem fs = new CompositeFilesystem(local, Map.of("MEMORY.md", storeFsys)); + + WriteResult result = fs.delete(RT, "MEMORY.md"); + assertTrue(result.isSuccess()); + assertNull(s.get(List.of("ns"), "/MEMORY.md")); + } + + // ================================================================ + // CompositeFilesystem — cross-backend move (store → local) + // ================================================================ + + @Test + void composite_move_crossBackend_storeToLocal(@TempDir Path tmp) throws Exception { + InMemoryStore s = new InMemoryStore(); + List ns = List.of("ns"); + s.put(ns, "/2025-01-01.md", Map.of("content", "diary", "encoding", "utf-8")); + RemoteFilesystem storeFsys = new RemoteFilesystem(s, ns); + LocalFilesystem local = new LocalFilesystem(tmp); + + CompositeFilesystem fs = new CompositeFilesystem(local, Map.of("memory/", storeFsys)); + + // Move from store-routed path to a local-only path + WriteResult result = fs.move(RT, "memory/2025-01-01.md", "archive/2025-01-01.md"); + assertTrue(result.isSuccess(), "cross-backend move should succeed"); + // Source removed from store + assertNull(s.get(ns, "/2025-01-01.md"), "source should be deleted from store"); + // Destination written to local disk + assertTrue( + Files.exists(tmp.resolve("archive/2025-01-01.md")), + "destination should appear on local disk"); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/RemoteFilesystemSpecTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/RemoteFilesystemSpecTest.java new file mode 100644 index 000000000..478315677 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/filesystem/RemoteFilesystemSpecTest.java @@ -0,0 +1,98 @@ +/* + * 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.filesystem; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.harness.agent.store.InMemoryStore; +import io.agentscope.harness.agent.store.NamespaceFactory; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class RemoteFilesystemSpecTest { + + private static final RuntimeContext RT = RuntimeContext.empty(); + + @TempDir Path workspace; + + @Test + void routesSharedPathsToStoreAndOthersToLocal() throws Exception { + InMemoryStore store = new InMemoryStore(); + AtomicReference userRef = new AtomicReference<>(); + NamespaceFactory localNs = () -> List.of("local-user"); + + AbstractFilesystem fs = + new RemoteFilesystemSpec(store) + .anonymousUserId("anon") + .toFilesystem(workspace, "agent-a", localNs, userRef::get); + + fs.uploadFiles( + RT, + List.of( + java.util.Map.entry( + "MEMORY.md", "hello".getBytes(StandardCharsets.UTF_8)))); + assertNotNull(store.get(List.of("agents", "agent-a", "users", "anon"), "/MEMORY.md")); + + fs.uploadFiles( + RT, + List.of( + java.util.Map.entry( + "knowledge/notes.md", "local".getBytes(StandardCharsets.UTF_8)))); + assertTrue(Files.isRegularFile(workspace.resolve("local-user/knowledge/notes.md"))); + } + + @Test + void resolvesNamespaceByRuntimeUserId() { + InMemoryStore store = new InMemoryStore(); + AtomicReference userRef = new AtomicReference<>("user-1"); + + AbstractFilesystem fs = + new RemoteFilesystemSpec(store) + .toFilesystem(workspace, "agent-a", List::of, userRef::get); + + fs.uploadFiles( + RT, + List.of(java.util.Map.entry("MEMORY.md", "v1".getBytes(StandardCharsets.UTF_8)))); + assertNotNull(store.get(List.of("agents", "agent-a", "users", "user-1"), "/MEMORY.md")); + } + + /** + * Mode 1 invariant: the composite filesystem produced by {@link RemoteFilesystemSpec} is + * not a sandbox filesystem, so the agent builder will not register the shell execute + * tool in this mode. + */ + @Test + void compositeModeIsNotASandboxFilesystem() { + InMemoryStore store = new InMemoryStore(); + AbstractFilesystem fs = + new RemoteFilesystemSpec(store) + .toFilesystem(workspace, "agent-a", List::of, () -> null); + + assertFalse( + fs instanceof AbstractSandboxFilesystem, + "Composite (non-sandbox) filesystem must NOT be an AbstractSandboxFilesystem" + + " — shell execution should be unavailable in Mode 1"); + assertTrue(fs instanceof CompositeFilesystem); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/MemoryConsolidatorFilesystemTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/MemoryConsolidatorFilesystemTest.java new file mode 100644 index 000000000..f2bb66446 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/MemoryConsolidatorFilesystemTest.java @@ -0,0 +1,148 @@ +/* + * 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.memory; + +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.harness.agent.filesystem.RemoteFilesystem; +import io.agentscope.harness.agent.store.InMemoryStore; +import io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Verifies that {@link MemoryConsolidator} reads daily ledgers and writes watermark / MEMORY.md + * entirely through {@link io.agentscope.harness.agent.filesystem.AbstractFilesystem}, making it + * backend-agnostic. + */ +class MemoryConsolidatorFilesystemTest { + + private static void seedStoreFile( + InMemoryStore store, List ns, String path, String content, Instant modifiedAt) { + Map value = + Map.of( + "content", + content, + "encoding", + "utf-8", + "modified_at", + modifiedAt.toString()); + store.put(ns, path, value); + } + + // ====================================================================== + // readWatermark: returns EPOCH when state file absent + // ====================================================================== + + @Test + void readWatermark_returnsEpochWhenStateAbsent(@TempDir Path tmp) { + InMemoryStore store = new InMemoryStore(); + List ns = List.of("test-ns"); + RemoteFilesystem fs = new RemoteFilesystem(store, ns); + WorkspaceManager wsm = new WorkspaceManager(tmp, fs); + + MemoryConsolidator consolidator = new MemoryConsolidator(wsm, null); + + assertEquals(Instant.EPOCH, consolidator.readWatermark()); + } + + // ====================================================================== + // readWatermark / writeWatermark round-trip through filesystem + // ====================================================================== + + @Test + void watermark_roundTripThroughFilesystem(@TempDir Path tmp) { + InMemoryStore store = new InMemoryStore(); + List ns = List.of("test-ns"); + RemoteFilesystem fs = new RemoteFilesystem(store, ns); + WorkspaceManager wsm = new WorkspaceManager(tmp, fs); + + MemoryConsolidator consolidator = new MemoryConsolidator(wsm, null); + + Instant ts = Instant.parse("2025-06-15T12:00:00Z"); + wsm.writeUtf8WorkspaceRelative(MemoryConsolidator.STATE_REL_PATH, ts.toString()); + + assertEquals(ts, consolidator.readWatermark()); + } + + // ====================================================================== + // readWatermark: no local file is touched — only the filesystem + // ====================================================================== + + @Test + void watermark_doesNotCreateLocalFile(@TempDir Path tmp) { + InMemoryStore store = new InMemoryStore(); + List ns = List.of("test-ns"); + RemoteFilesystem fs = new RemoteFilesystem(store, ns); + WorkspaceManager wsm = new WorkspaceManager(tmp, fs); + + MemoryConsolidator consolidator = new MemoryConsolidator(wsm, null); + + Instant ts = Instant.now(); + wsm.writeUtf8WorkspaceRelative(MemoryConsolidator.STATE_REL_PATH, ts.toString()); + + // local disk must NOT have the state file — it lives only in the store + Path localState = tmp.resolve("memory").resolve(MemoryConsolidator.STATE_FILE); + assertFalse( + Files.exists(localState), + "state file should not be written to local disk when using RemoteFilesystem"); + + // but consolidator reads it correctly from the store + assertEquals(ts, consolidator.readWatermark()); + } + + // ====================================================================== + // STATE_FILE constant is preserved + // ====================================================================== + + @Test + void stateFileRelPath_matchesConstant() { + assertEquals("memory/" + MemoryConsolidator.STATE_FILE, MemoryConsolidator.STATE_REL_PATH); + } + + // ====================================================================== + // Local filesystem (no store) — watermark uses local disk via WorkspaceManager + // ====================================================================== + + @Test + void watermark_localFallback_whenNoFilesystem(@TempDir Path tmp) throws Exception { + WorkspaceManager wsm = new WorkspaceManager(tmp); + + MemoryConsolidator consolidator = new MemoryConsolidator(wsm, null); + + // No file → EPOCH + assertEquals(Instant.EPOCH, consolidator.readWatermark()); + + // Write via WorkspaceManager (falls to local disk) + Instant ts = Instant.parse("2025-03-10T09:00:00Z"); + wsm.writeUtf8WorkspaceRelative(MemoryConsolidator.STATE_REL_PATH, ts.toString()); + + assertEquals(ts, consolidator.readWatermark()); + + // Verify the local file actually exists + Path localState = tmp.resolve("memory").resolve(MemoryConsolidator.STATE_FILE); + assertTrue( + Files.exists(localState), + "state file should be written to local disk when no filesystem is configured"); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/session/SessionTreeMirrorTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/session/SessionTreeMirrorTest.java new file mode 100644 index 000000000..c2af8c2ba --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/memory/session/SessionTreeMirrorTest.java @@ -0,0 +1,204 @@ +/* + * 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.memory.session; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import io.agentscope.harness.agent.filesystem.AbstractFilesystem; +import io.agentscope.harness.agent.filesystem.RemoteFilesystemSpec; +import io.agentscope.harness.agent.store.InMemoryStore; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class SessionTreeMirrorTest { + + @TempDir Path workspace; + + private AbstractFilesystem buildFs(InMemoryStore store) { + return new RemoteFilesystemSpec(store) + .toFilesystem(workspace, "agent-a", List::of, () -> "user-1"); + } + + // ----------------------------------------------------------------------- + // load(): local-only, cold-start restore when local file is absent + // ----------------------------------------------------------------------- + + @Test + void load_restoresFromRemote_whenLocalFilesMissing() throws Exception { + InMemoryStore store = new InMemoryStore(); + AbstractFilesystem fs = buildFs(store); + Path context = workspace.resolve("agents/agent-a/sessions/s1.jsonl"); + + // Machine A writes and flushes (mirrors to remote) + SessionTree writer = new SessionTree(context, workspace, fs); + writer.append(new SessionEntry.MessageEntry(null, null, null, "USER", "hello", null)); + writer.flush(); + awaitMirror(); + + // Delete local files — simulate cold-start on a new machine + Files.deleteIfExists(context); + Files.deleteIfExists(writer.getLogFile()); + + // Machine B: load() alone should restore from remote (cold-start path) + SessionTree reader = new SessionTree(context, workspace, fs); + reader.load(); + + assertEquals(1, reader.size(), "load() should restore from remote when local is absent"); + assertTrue(Files.isRegularFile(context), "local file should be recreated"); + } + + @Test + void load_readsLocalOnly_whenLocalFileExists() throws Exception { + InMemoryStore store = new InMemoryStore(); + AbstractFilesystem fs = buildFs(store); + Path context = workspace.resolve("agents/agent-a/sessions/s1b.jsonl"); + + // Write two entries; mirror first entry to remote, keep second local-only + SessionTree t1 = new SessionTree(context, workspace, fs); + t1.append(new SessionEntry.MessageEntry(null, null, null, "USER", "entry-1", null)); + t1.flush(); + awaitMirror(); // remote has entry-1 + + SessionTree t2 = new SessionTree(context, workspace, fs); + t2.load(); // local has entry-1 + t2.append(new SessionEntry.MessageEntry(null, null, null, "ASSISTANT", "entry-2", null)); + t2.flush(); // local has entry-1+2; async mirror not awaited + + // A new SessionTree.load() on the same local file should see both entries immediately + // (local has them) WITHOUT touching remote. + SessionTree t3 = new SessionTree(context, workspace, fs); + t3.load(); + assertEquals(2, t3.size(), "load() should read both entries from local cache"); + } + + // ----------------------------------------------------------------------- + // syncFromRemote(): cross-machine handoff + // ----------------------------------------------------------------------- + + @Test + void syncFromRemote_mergesRemoteAheadEntries_intoStaleLocal() throws Exception { + InMemoryStore store = new InMemoryStore(); + AbstractFilesystem fs = buildFs(store); + Path context = workspace.resolve("agents/agent-a/sessions/s2.jsonl"); + + // Round 1 — Machine A: write, flush, mirror to remote + SessionTree machineA = new SessionTree(context, workspace, fs); + machineA.append(new SessionEntry.MessageEntry(null, null, null, "USER", "round-1", null)); + machineA.flush(); + awaitMirror(); // remote: round-1 + + // Simulate machine B having a stale local: only round-1 on disk. + // Machine A then writes round-2 and mirrors. + SessionTree machineA2 = new SessionTree(context, workspace, fs); + machineA2.load(); + machineA2.append( + new SessionEntry.MessageEntry(null, null, null, "ASSISTANT", "round-2", null)); + machineA2.flush(); + awaitMirror(); // remote: round-1 + round-2 + + // Truncate local to only round-1 (simulate stale machine B) + List lines = Files.readAllLines(context); + Files.writeString(context, lines.get(0) + "\n"); + + // Machine B: load() sees local (stale, round-1 only) + SessionTree machineB = new SessionTree(context, workspace, fs); + machineB.load(); + assertEquals(1, machineB.size(), "load() should only see local (stale) content"); + + // syncFromRemote() picks up round-2 from remote + machineB.syncFromRemote(); + assertEquals(2, machineB.size(), "syncFromRemote() should union-merge round-2 from remote"); + } + + @Test + void syncFromRemote_preservesLocalOnlyEntries_notYetPushed() throws Exception { + InMemoryStore store = new InMemoryStore(); + AbstractFilesystem fs = buildFs(store); + Path context = workspace.resolve("agents/agent-a/sessions/s3.jsonl"); + + // Write entry-1 and mirror + SessionTree t1 = new SessionTree(context, workspace, fs); + t1.append(new SessionEntry.MessageEntry(null, null, null, "USER", "entry-1", null)); + t1.flush(); + awaitMirror(); // remote: entry-1 + + // Write entry-2 locally but don't wait for mirror (still in-flight) + SessionTree t2 = new SessionTree(context, workspace, fs); + t2.load(); + t2.append(new SessionEntry.MessageEntry(null, null, null, "ASSISTANT", "entry-2", null)); + t2.flush(); // local: entry-1+2; remote may still only have entry-1 + + // Load a fresh tree — local has entry-1+2 + SessionTree t3 = new SessionTree(context, workspace, fs); + t3.load(); + assertEquals(2, t3.size()); + + // syncFromRemote() — remote has entry-1 only; local-only entry-2 must survive + t3.syncFromRemote(); + assertEquals(2, t3.size(), "local-only entry-2 must survive union-merge"); + assertTrue( + t3.getMessageEntries().stream().anyMatch(m -> "entry-2".equals(m.getContent())), + "entry-2 must be present after sync"); + } + + @Test + void syncFromRemote_isNoOp_whenFilesystemIsNull() throws Exception { + Path context = workspace.resolve("agents/agent-a/sessions/s4.jsonl"); + SessionTree tree = new SessionTree(context, workspace, null); + tree.append(new SessionEntry.MessageEntry(null, null, null, "USER", "local", null)); + tree.flush(); + + SessionTree reader = new SessionTree(context, workspace, null); + reader.load(); + reader.syncFromRemote(); // must not throw + assertEquals(1, reader.size()); + } + + // ----------------------------------------------------------------------- + // flush(): local write is synchronous, remote mirror is async + // ----------------------------------------------------------------------- + + @Test + void flush_localWriteCompletesImmediately() throws Exception { + InMemoryStore store = new InMemoryStore(); + AbstractFilesystem fs = buildFs(store); + Path context = workspace.resolve("agents/agent-a/sessions/s5.jsonl"); + + SessionTree tree = new SessionTree(context, workspace, fs); + tree.append(new SessionEntry.MessageEntry(null, null, null, "USER", "hi", null)); + tree.flush(); + + assertTrue( + Files.isRegularFile(context), + "local context file must exist immediately after flush()"); + assertTrue( + Files.isRegularFile(tree.getLogFile()), + "local log file must exist immediately after flush()"); + } + + // ----------------------------------------------------------------------- + // Helper + // ----------------------------------------------------------------------- + + private static void awaitMirror() throws InterruptedException { + TimeUnit.MILLISECONDS.sleep(300); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxIsolationKeyTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxIsolationKeyTest.java new file mode 100644 index 000000000..e13ddcfcf --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxIsolationKeyTest.java @@ -0,0 +1,138 @@ +/* + * 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.sandbox; + +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.core.state.SimpleSessionKey; +import io.agentscope.harness.agent.IsolationScope; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class SandboxIsolationKeyTest { + + private static final String AGENT_ID = "my-agent"; + + @Test + void sessionScope_withSessionKey_resolvesCorrectly() { + RuntimeContext ctx = + RuntimeContext.builder().sessionKey(SimpleSessionKey.of("sess-abc")).build(); + Optional key = + SandboxIsolationKey.resolve(IsolationScope.SESSION, ctx, AGENT_ID); + assertTrue(key.isPresent()); + assertEquals(IsolationScope.SESSION, key.get().getScope()); + assertEquals("sess-abc", key.get().getValue()); + } + + @Test + void sessionScope_missingSessionKey_returnsEmpty() { + RuntimeContext ctx = RuntimeContext.builder().build(); + Optional key = + SandboxIsolationKey.resolve(IsolationScope.SESSION, ctx, AGENT_ID); + assertFalse(key.isPresent()); + } + + @Test + void sessionScope_nullContext_returnsEmpty() { + Optional key = + SandboxIsolationKey.resolve(IsolationScope.SESSION, null, AGENT_ID); + assertFalse(key.isPresent()); + } + + @Test + void nullScope_treatedAsSession_withSessionKey() { + RuntimeContext ctx = + RuntimeContext.builder().sessionKey(SimpleSessionKey.of("sess-def")).build(); + Optional key = + SandboxIsolationKey.resolve((IsolationScope) null, ctx, AGENT_ID); + assertTrue(key.isPresent()); + assertEquals(IsolationScope.SESSION, key.get().getScope()); + assertEquals("sess-def", key.get().getValue()); + } + + @Test + void userScope_withUserId_resolvesCorrectly() { + RuntimeContext ctx = RuntimeContext.builder().userId("user-123").build(); + Optional key = + SandboxIsolationKey.resolve(IsolationScope.USER, ctx, AGENT_ID); + assertTrue(key.isPresent()); + assertEquals(IsolationScope.USER, key.get().getScope()); + assertEquals("user-123", key.get().getValue()); + } + + @Test + void userScope_blankUserId_returnsEmpty() { + RuntimeContext ctx = RuntimeContext.builder().userId(" ").build(); + Optional key = + SandboxIsolationKey.resolve(IsolationScope.USER, ctx, AGENT_ID); + assertFalse(key.isPresent()); + } + + @Test + void userScope_nullUserId_returnsEmpty() { + RuntimeContext ctx = RuntimeContext.builder().build(); + Optional key = + SandboxIsolationKey.resolve(IsolationScope.USER, ctx, AGENT_ID); + assertFalse(key.isPresent()); + } + + @Test + void userScope_nullContext_returnsEmpty() { + Optional key = + SandboxIsolationKey.resolve(IsolationScope.USER, null, AGENT_ID); + assertFalse(key.isPresent()); + } + + @Test + void agentScope_alwaysResolvesToAgentId() { + Optional keyWithCtx = + SandboxIsolationKey.resolve( + IsolationScope.AGENT, RuntimeContext.builder().build(), AGENT_ID); + Optional keyNullCtx = + SandboxIsolationKey.resolve(IsolationScope.AGENT, null, AGENT_ID); + + assertTrue(keyWithCtx.isPresent()); + assertEquals(IsolationScope.AGENT, keyWithCtx.get().getScope()); + assertEquals(AGENT_ID, keyWithCtx.get().getValue()); + + assertTrue(keyNullCtx.isPresent()); + assertEquals(AGENT_ID, keyNullCtx.get().getValue()); + } + + @Test + void globalScope_alwaysResolvesToGlobalValue() { + Optional key = + SandboxIsolationKey.resolve(IsolationScope.GLOBAL, null, AGENT_ID); + assertTrue(key.isPresent()); + assertEquals(IsolationScope.GLOBAL, key.get().getScope()); + assertEquals(SandboxIsolationKey.GLOBAL_VALUE, key.get().getValue()); + } + + @Test + void equalsAndHashCode_sameValues_areEqual() { + RuntimeContext ctx = RuntimeContext.builder().sessionKey(SimpleSessionKey.of("s1")).build(); + Optional k1 = + SandboxIsolationKey.resolve(IsolationScope.SESSION, ctx, AGENT_ID); + Optional k2 = + SandboxIsolationKey.resolve(IsolationScope.SESSION, ctx, AGENT_ID); + assertTrue(k1.isPresent() && k2.isPresent()); + assertEquals(k1.get(), k2.get()); + assertEquals(k1.get().hashCode(), k2.get().hashCode()); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxManagerIsolationTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxManagerIsolationTest.java new file mode 100644 index 000000000..f97233394 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SandboxManagerIsolationTest.java @@ -0,0 +1,253 @@ +/* + * 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.sandbox; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import io.agentscope.core.agent.RuntimeContext; +import io.agentscope.core.state.SimpleSessionKey; +import io.agentscope.harness.agent.IsolationScope; +import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class SandboxManagerIsolationTest { + + private static final String AGENT_ID = "test-agent"; + private static final String STATE_JSON = "{\"sessionId\":\"s42\"}"; + + @Mock SandboxClient client; + @Mock SandboxStateStore stateStore; + @Mock Sandbox freshSandbox; + @Mock Sandbox resumedSandbox; + @Mock Sandbox externalSandbox; + @Mock SandboxState externalState; + @Mock SandboxState resumedState; + @Mock SandboxSnapshotSpec snapshotSpec; + + SandboxManager manager; + + @BeforeEach + void setUp() { + manager = new SandboxManager(client, stateStore, AGENT_ID); + } + + // ---- Priority 1: user-managed external session ---- + + @Test + void priority1_externalSession_usedDirectly() throws Exception { + SandboxContext ctx = SandboxContext.builder().externalSandbox(externalSandbox).build(); + + SandboxAcquireResult result = manager.acquire(ctx, null); + + assertSame(externalSandbox, result.getSandbox()); + assertEquals(false, result.isSelfManaged()); + verify(stateStore, never()).load(any()); + } + + // ---- Priority 2: explicit session state ---- + + @Test + void priority2_externalSessionState_resumedDirectly() throws Exception { + when(externalState.getSessionId()).thenReturn("explicit-id"); + when(client.resume(externalState)).thenReturn(resumedSandbox); + + SandboxContext ctx = SandboxContext.builder().externalSandboxState(externalState).build(); + + SandboxAcquireResult result = manager.acquire(ctx, null); + + assertSame(resumedSandbox, result.getSandbox()); + assertEquals(true, result.isSelfManaged()); + verify(stateStore, never()).load(any()); + } + + // ---- Priority 3: state store hit (session scope) ---- + + @Test + void priority3_stateStoreHit_resumesSession() throws Exception { + when(stateStore.load(any())).thenReturn(Optional.of(STATE_JSON)); + when(client.deserializeState(STATE_JSON)).thenReturn(resumedState); + when(client.resume(resumedState)).thenReturn(resumedSandbox); + + RuntimeContext rtx = + RuntimeContext.builder().sessionKey(SimpleSessionKey.of("sess-1")).build(); + SandboxContext sCtx = + SandboxContext.builder().isolationScope(IsolationScope.SESSION).build(); + + SandboxAcquireResult result = manager.acquire(sCtx, rtx); + + assertSame(resumedSandbox, result.getSandbox()); + assertEquals(true, result.isSelfManaged()); + verify(client, never()).create(any(), any(), any()); + } + + // ---- Priority 3: state store miss → Priority 4 fresh create ---- + + @Test + void priority3_stateStoreMiss_createsFreshSession() throws Exception { + when(stateStore.load(any())).thenReturn(Optional.empty()); + when(client.create(any(), any(), any())).thenReturn(freshSandbox); + + RuntimeContext rtx = + RuntimeContext.builder().sessionKey(SimpleSessionKey.of("sess-2")).build(); + SandboxContext sCtx = + SandboxContext.builder() + .isolationScope(IsolationScope.SESSION) + .snapshotSpec(snapshotSpec) + .build(); + + SandboxAcquireResult result = manager.acquire(sCtx, rtx); + + assertSame(freshSandbox, result.getSandbox()); + verify(client).create(any(), any(), any()); + } + + // ---- Priority 4 (no session key → scope key empty → fresh create) ---- + + @Test + void noScopeKey_createsFreshSession() throws Exception { + when(client.create(any(), any(), any())).thenReturn(freshSandbox); + + RuntimeContext rtx = RuntimeContext.builder().build(); // no sessionKey + SandboxContext sCtx = SandboxContext.builder().build(); // scope = SESSION (default) + + SandboxAcquireResult result = manager.acquire(sCtx, rtx); + + assertSame(freshSandbox, result.getSandbox()); + verify(stateStore, never()).load(any()); + } + + // ---- USER scope ---- + + @Test + void userScope_withUserId_loadsFromStore() throws Exception { + when(stateStore.load(any())).thenReturn(Optional.of(STATE_JSON)); + when(client.deserializeState(STATE_JSON)).thenReturn(resumedState); + when(client.resume(resumedState)).thenReturn(resumedSandbox); + + RuntimeContext rtx = RuntimeContext.builder().userId("user-42").build(); + SandboxContext sCtx = SandboxContext.builder().isolationScope(IsolationScope.USER).build(); + + SandboxAcquireResult result = manager.acquire(sCtx, rtx); + + assertSame(resumedSandbox, result.getSandbox()); + } + + @Test + void userScope_missingUserId_createsFreshSession() throws Exception { + when(client.create(any(), any(), any())).thenReturn(freshSandbox); + + RuntimeContext rtx = RuntimeContext.builder().build(); // no userId + SandboxContext sCtx = SandboxContext.builder().isolationScope(IsolationScope.USER).build(); + + SandboxAcquireResult result = manager.acquire(sCtx, rtx); + + assertSame(freshSandbox, result.getSandbox()); + verify(stateStore, never()).load(any()); + } + + // ---- AGENT scope ---- + + @Test + void agentScope_alwaysHasScopeKey() throws Exception { + when(stateStore.load(any())).thenReturn(Optional.empty()); + when(client.create(any(), any(), any())).thenReturn(freshSandbox); + + SandboxContext sCtx = SandboxContext.builder().isolationScope(IsolationScope.AGENT).build(); + + SandboxAcquireResult result = manager.acquire(sCtx, null); + + assertNotNull(result.getSandbox()); + verify(stateStore).load(any()); // scope key resolved; store was queried + } + + // ---- GLOBAL scope ---- + + @Test + void globalScope_alwaysHasScopeKey() throws Exception { + when(stateStore.load(any())).thenReturn(Optional.empty()); + when(client.create(any(), any(), any())).thenReturn(freshSandbox); + + SandboxContext sCtx = + SandboxContext.builder().isolationScope(IsolationScope.GLOBAL).build(); + + SandboxAcquireResult result = manager.acquire(sCtx, null); + + assertNotNull(result.getSandbox()); + verify(stateStore).load(any()); + } + + // ---- persistState ---- + + @Test + void persistState_savesJsonForResolvedScopeKey() throws Exception { + SandboxState state = mock(SandboxState.class); + when(state.getSessionId()).thenReturn("sid"); + Sandbox sandbox = mock(Sandbox.class); + when(sandbox.getState()).thenReturn(state); + when(client.serializeState(state)).thenReturn(STATE_JSON); + SandboxAcquireResult result = SandboxAcquireResult.selfManaged(sandbox); + + RuntimeContext rtx = + RuntimeContext.builder().sessionKey(SimpleSessionKey.of("sess-p")).build(); + SandboxContext sCtx = + SandboxContext.builder().isolationScope(IsolationScope.SESSION).build(); + + manager.persistState(result, sCtx, rtx); + + verify(stateStore).save(any(), any()); + } + + @Test + void persistState_missingScopeKey_skipped() throws Exception { + Sandbox sandbox = mock(Sandbox.class); + when(sandbox.getState()).thenReturn(mock(SandboxState.class)); + SandboxAcquireResult result = SandboxAcquireResult.selfManaged(sandbox); + + RuntimeContext rtx = RuntimeContext.builder().build(); // no session key + SandboxContext sCtx = SandboxContext.builder().build(); // SESSION scope by default + + manager.persistState(result, sCtx, rtx); + + verify(stateStore, never()).save(any(), any()); + } + + // ---- clearState ---- + + @Test + void clearState_deletesFromStore() throws Exception { + RuntimeContext rtx = + RuntimeContext.builder().sessionKey(SimpleSessionKey.of("sess-c")).build(); + SandboxContext sCtx = + SandboxContext.builder().isolationScope(IsolationScope.SESSION).build(); + + manager.clearState(sCtx, rtx); + + verify(stateStore).delete(any()); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SessionSandboxStateStoreTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SessionSandboxStateStoreTest.java new file mode 100644 index 000000000..0578a9b2c --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/SessionSandboxStateStoreTest.java @@ -0,0 +1,110 @@ +/* + * 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.sandbox; + +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.core.session.InMemorySession; +import io.agentscope.core.state.SimpleSessionKey; +import io.agentscope.harness.agent.IsolationScope; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class SessionSandboxStateStoreTest { + + private static final String AGENT_ID = "test-agent"; + private static final String JSON = "{\"sessionId\":\"s-1\"}"; + + private SessionSandboxStateStore store; + + @BeforeEach + void setUp() { + store = new SessionSandboxStateStore(new InMemorySession(), AGENT_ID); + } + + @Test + void sessionScope_roundTrip() throws Exception { + SandboxIsolationKey key = isolationKey(IsolationScope.SESSION, "sess-001"); + assertFalse(store.load(key).isPresent()); + + store.save(key, JSON); + assertEquals(JSON, store.load(key).orElseThrow()); + + store.delete(key); + assertFalse(store.load(key).isPresent()); + } + + @Test + void userScope_roundTrip() throws Exception { + SandboxIsolationKey key = isolationKey(IsolationScope.USER, "user-123"); + store.save(key, JSON); + assertEquals(JSON, store.load(key).orElseThrow()); + store.delete(key); + assertFalse(store.load(key).isPresent()); + } + + @Test + void agentAndGlobalScope_doNotInterfere() throws Exception { + SandboxIsolationKey agentKey = isolationKey(IsolationScope.AGENT, AGENT_ID); + SandboxIsolationKey globalKey = + isolationKey(IsolationScope.GLOBAL, SandboxIsolationKey.GLOBAL_VALUE); + + store.save(agentKey, "{\"scope\":\"agent\"}"); + store.save(globalKey, "{\"scope\":\"global\"}"); + + assertEquals("{\"scope\":\"agent\"}", store.load(agentKey).orElseThrow()); + assertEquals("{\"scope\":\"global\"}", store.load(globalKey).orElseThrow()); + } + + @Test + void deleteUsesTombstone_evenWhenSessionDeleteUnsupported() throws Exception { + SessionSandboxStateStore redisLikeStore = + new SessionSandboxStateStore(new NoDeleteSession(), AGENT_ID); + SandboxIsolationKey key = isolationKey(IsolationScope.SESSION, "sess-del"); + + redisLikeStore.save(key, JSON); + assertTrue(redisLikeStore.load(key).isPresent()); + + redisLikeStore.delete(key); + assertFalse(redisLikeStore.load(key).isPresent()); + } + + private static SandboxIsolationKey isolationKey(IsolationScope scope, String value) { + return SandboxIsolationKey.resolve(scope, runtimeContext(scope, value), AGENT_ID) + .orElseThrow(); + } + + private static RuntimeContext runtimeContext(IsolationScope scope, String value) { + RuntimeContext.Builder b = RuntimeContext.builder(); + if (scope == IsolationScope.SESSION) { + b.sessionKey(SimpleSessionKey.of(value)); + } else if (scope == IsolationScope.USER) { + b.userId(value); + } + return b.build(); + } + + /** Simulates sessions whose per-key delete is not implemented (default no-op). */ + private static final class NoDeleteSession extends InMemorySession { + @Override + public void delete(io.agentscope.core.state.SessionKey sessionKey, String key) { + // no-op + } + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/WorkspaceProjectionApplierTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/WorkspaceProjectionApplierTest.java new file mode 100644 index 000000000..759a37e0d --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/WorkspaceProjectionApplierTest.java @@ -0,0 +1,91 @@ +/* + * 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.sandbox; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +import io.agentscope.harness.agent.sandbox.layout.WorkspaceProjectionEntry; +import java.io.ByteArrayInputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class WorkspaceProjectionApplierTest { + + @TempDir Path tempDir; + + @Test + void build_returnsNullWhenNoProjectionEntry() throws Exception { + WorkspaceSpec spec = new WorkspaceSpec(); + spec.getEntries().clear(); + assertNull(WorkspaceProjectionApplier.build(spec)); + } + + @Test + void build_producesDeterministicHashAndTar() throws Exception { + Path source = tempDir.resolve("source"); + Files.createDirectories(source.resolve("skills/java")); + Files.writeString(source.resolve("AGENTS.md"), "v1"); + Files.writeString(source.resolve("skills/java/SKILL.md"), "skill"); + Files.writeString(source.resolve("other.txt"), "ignored"); + + WorkspaceProjectionEntry projection = new WorkspaceProjectionEntry(); + projection.setSourceRoot(source.toString()); + projection.setIncludeRoots(java.util.List.of("AGENTS.md", "skills")); + + WorkspaceSpec spec = new WorkspaceSpec(); + spec.getEntries().put("__workspace_projection__", projection); + + WorkspaceProjectionApplier.ProjectionPayload p1 = WorkspaceProjectionApplier.build(spec); + WorkspaceProjectionApplier.ProjectionPayload p2 = WorkspaceProjectionApplier.build(spec); + assertNotNull(p1); + assertNotNull(p2); + assertEquals(p1.hash(), p2.hash()); + assertEquals(2, p1.fileCount()); + + Path dest = tempDir.resolve("dest"); + Files.createDirectories(dest); + WorkspaceArchiveExtractor.extractTarArchive(dest, new ByteArrayInputStream(p1.tarBytes())); + assertEquals("v1", Files.readString(dest.resolve("AGENTS.md"))); + assertEquals("skill", Files.readString(dest.resolve("skills/java/SKILL.md"))); + } + + @Test + void build_hashChangesWhenProjectedContentChanges() throws Exception { + Path source = tempDir.resolve("source2"); + Files.createDirectories(source); + Files.writeString(source.resolve("AGENTS.md"), "v1"); + + WorkspaceProjectionEntry projection = new WorkspaceProjectionEntry(); + projection.setSourceRoot(source.toString()); + projection.setIncludeRoots(java.util.List.of("AGENTS.md")); + + WorkspaceSpec spec = new WorkspaceSpec(); + spec.getEntries().put("__workspace_projection__", projection); + + WorkspaceProjectionApplier.ProjectionPayload p1 = WorkspaceProjectionApplier.build(spec); + Files.writeString(source.resolve("AGENTS.md"), "v2"); + WorkspaceProjectionApplier.ProjectionPayload p2 = WorkspaceProjectionApplier.build(spec); + + assertNotNull(p1); + assertNotNull(p2); + assertNotEquals(p1.hash(), p2.hash()); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/WorkspaceSandboxStateStoreTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/WorkspaceSandboxStateStoreTest.java new file mode 100644 index 000000000..c82d38092 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/WorkspaceSandboxStateStoreTest.java @@ -0,0 +1,234 @@ +/* + * 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.sandbox; + +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.state.SimpleSessionKey; +import io.agentscope.harness.agent.IsolationScope; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class WorkspaceSandboxStateStoreTest { + + @TempDir Path workspace; + + private static final String AGENT_ID = "test-agent"; + private static final String JSON = "{\"sessionId\":\"s1\"}"; + + private WorkspaceSandboxStateStore store; + + @BeforeEach + void setUp() { + store = new WorkspaceSandboxStateStore(workspace, AGENT_ID); + } + + // ---- SESSION scope ---- + + @Test + void sessionScope_saveLoadDelete_roundTrip() throws Exception { + SandboxIsolationKey key = isolationKey(IsolationScope.SESSION, "sess-001"); + + assertFalse(store.load(key).isPresent()); + + store.save(key, JSON); + Optional loaded = store.load(key); + assertTrue(loaded.isPresent()); + assertEquals(JSON, loaded.get()); + + // Verify session-scoped state location + Path expected = + workspace + .resolve("agents") + .resolve(AGENT_ID) + .resolve("context") + .resolve("sess-001") + .resolve("_sandbox.json"); + assertTrue(Files.exists(expected)); + + store.delete(key); + assertFalse(store.load(key).isPresent()); + } + + // ---- USER scope ---- + + @Test + void userScope_saveLoadDelete_roundTrip() throws Exception { + SandboxIsolationKey key = isolationKey(IsolationScope.USER, "user-abc"); + + store.save(key, JSON); + Optional loaded = store.load(key); + assertTrue(loaded.isPresent()); + assertEquals(JSON, loaded.get()); + + Path expected = + workspace + .resolve("agents") + .resolve(AGENT_ID) + .resolve("sandboxes") + .resolve("user") + .resolve("user-abc.json"); + assertTrue(Files.exists(expected)); + + store.delete(key); + assertFalse(store.load(key).isPresent()); + } + + @Test + void userScope_unsafeCharacters_encodedInFilename() throws Exception { + String userId = "user@example.com/special"; + SandboxIsolationKey key = isolationKey(IsolationScope.USER, userId); + + store.save(key, JSON); + assertTrue(store.load(key).isPresent()); + + // Filename must not contain the raw special chars + Path userDir = + workspace.resolve("agents").resolve(AGENT_ID).resolve("sandboxes").resolve("user"); + boolean rawFileExists = Files.exists(userDir.resolve(userId + ".json")); + assertFalse(rawFileExists, "Raw unsafe filename should not exist"); + + // A Base64-encoded file should exist instead + long encodedFiles = Files.list(userDir).count(); + assertEquals(1, encodedFiles); + + store.delete(key); + assertFalse(store.load(key).isPresent()); + } + + // ---- AGENT scope ---- + + @Test + void agentScope_saveLoadDelete_roundTrip() throws Exception { + SandboxIsolationKey key = isolationKey(IsolationScope.AGENT, AGENT_ID); + + store.save(key, JSON); + Optional loaded = store.load(key); + assertTrue(loaded.isPresent()); + assertEquals(JSON, loaded.get()); + + Path expected = + workspace + .resolve("agents") + .resolve(AGENT_ID) + .resolve("sandboxes") + .resolve("agent.json"); + assertTrue(Files.exists(expected)); + + store.delete(key); + assertFalse(store.load(key).isPresent()); + } + + // ---- GLOBAL scope ---- + + @Test + void globalScope_saveLoadDelete_roundTrip() throws Exception { + SandboxIsolationKey key = + isolationKey(IsolationScope.GLOBAL, SandboxIsolationKey.GLOBAL_VALUE); + + store.save(key, JSON); + Optional loaded = store.load(key); + assertTrue(loaded.isPresent()); + assertEquals(JSON, loaded.get()); + + Path expected = workspace.resolve("sandboxes").resolve("global.json"); + assertTrue(Files.exists(expected)); + + store.delete(key); + assertFalse(store.load(key).isPresent()); + } + + @Test + void multipleScopes_doNotInterfere() throws Exception { + SandboxIsolationKey sessionKey = isolationKey(IsolationScope.SESSION, "sess-x"); + SandboxIsolationKey userKey = isolationKey(IsolationScope.USER, "user-x"); + SandboxIsolationKey agentKey = isolationKey(IsolationScope.AGENT, AGENT_ID); + SandboxIsolationKey globalKey = + isolationKey(IsolationScope.GLOBAL, SandboxIsolationKey.GLOBAL_VALUE); + + store.save(sessionKey, "{\"scope\":\"session\"}"); + store.save(userKey, "{\"scope\":\"user\"}"); + store.save(agentKey, "{\"scope\":\"agent\"}"); + store.save(globalKey, "{\"scope\":\"global\"}"); + + assertEquals("{\"scope\":\"session\"}", store.load(sessionKey).orElseThrow()); + assertEquals("{\"scope\":\"user\"}", store.load(userKey).orElseThrow()); + assertEquals("{\"scope\":\"agent\"}", store.load(agentKey).orElseThrow()); + assertEquals("{\"scope\":\"global\"}", store.load(globalKey).orElseThrow()); + } + + @Test + void save_overwrites_existingValue() throws Exception { + SandboxIsolationKey key = isolationKey(IsolationScope.AGENT, AGENT_ID); + store.save(key, "{\"v\":1}"); + store.save(key, "{\"v\":2}"); + assertEquals("{\"v\":2}", store.load(key).orElseThrow()); + } + + @Test + void delete_nonExistent_isNoOp() throws Exception { + SandboxIsolationKey key = isolationKey(IsolationScope.USER, "nobody"); + // Should not throw + store.delete(key); + } + + @Test + void sessionScope_usesDirectFileIo() throws Exception { + WorkspaceSandboxStateStore storeNoSession = + new WorkspaceSandboxStateStore(workspace, AGENT_ID); + SandboxIsolationKey key = isolationKey(IsolationScope.SESSION, "sess-direct"); + + storeNoSession.save(key, JSON); + Optional loaded = storeNoSession.load(key); + assertTrue(loaded.isPresent()); + assertEquals(JSON, loaded.get()); + + storeNoSession.delete(key); + assertFalse(storeNoSession.load(key).isPresent()); + } + + // ---- helpers ---- + + private static SandboxIsolationKey isolationKey(IsolationScope scope, String value) { + return SandboxIsolationKey.resolve( + scope, + buildCtxForScope(scope, value), + value.startsWith("user") || scope == IsolationScope.GLOBAL + ? "test-agent" + : value) + .orElseThrow(() -> new IllegalStateException("Key could not be resolved")); + } + + private static io.agentscope.core.agent.RuntimeContext buildCtxForScope( + IsolationScope scope, String value) { + io.agentscope.core.agent.RuntimeContext.Builder b = + io.agentscope.core.agent.RuntimeContext.builder(); + switch (scope) { + case SESSION -> b.sessionKey(SimpleSessionKey.of(value)); + case USER -> b.userId(value); + default -> { + /* AGENT and GLOBAL do not need context fields */ + } + } + return b.build(); + } +} diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/snapshot/RemoteSnapshotClientsTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/snapshot/RemoteSnapshotClientsTest.java new file mode 100644 index 000000000..1f635ef53 --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/sandbox/snapshot/RemoteSnapshotClientsTest.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.harness.agent.sandbox.snapshot; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.aliyun.oss.OSS; +import com.aliyun.oss.model.OSSObject; +import java.io.ByteArrayInputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import redis.clients.jedis.UnifiedJedis; + +class RemoteSnapshotClientsTest { + + @Test + void ossClient_uploadDownloadExists() throws Exception { + OSS oss = mock(OSS.class); + OSSObject object = new OSSObject(); + object.setObjectContent( + new ByteArrayInputStream("payload".getBytes(StandardCharsets.UTF_8))); + when(oss.doesObjectExist("bucket", "prefix/s1.tar")).thenReturn(true); + when(oss.getObject("bucket", "prefix/s1.tar")).thenReturn(object); + + OssRemoteSnapshotClient client = new OssRemoteSnapshotClient(oss, "bucket", "prefix"); + client.upload("s1", new ByteArrayInputStream("payload".getBytes(StandardCharsets.UTF_8))); + assertEquals(true, client.exists("s1")); + String downloaded = + new String(client.download("s1").readAllBytes(), StandardCharsets.UTF_8); + assertEquals("payload", downloaded); + + verify(oss).putObject(eq("bucket"), eq("prefix/s1.tar"), any(InputStream.class)); + } + + @Test + void redisClient_uploadDownloadWithTtl() throws Exception { + UnifiedJedis jedis = mock(UnifiedJedis.class); + byte[] data = "data".getBytes(StandardCharsets.UTF_8); + when(jedis.get(any(byte[].class))).thenReturn(data); + when(jedis.exists(any(byte[].class))).thenReturn(true); + + RedisRemoteSnapshotClient client = new RedisRemoteSnapshotClient(jedis, "snap", 60); + client.upload("s1", new ByteArrayInputStream(data)); + + verify(jedis).set(any(byte[].class), eq(data)); + verify(jedis).expire(any(byte[].class), eq(60L)); + + assertEquals( + "data", new String(client.download("s1").readAllBytes(), StandardCharsets.UTF_8)); + assertEquals(true, client.exists("s1")); + } + + @Test + void redisClient_downloadMissing_throws() throws Exception { + UnifiedJedis jedis = mock(UnifiedJedis.class); + when(jedis.get(any(byte[].class))).thenReturn(null); + + RedisRemoteSnapshotClient client = new RedisRemoteSnapshotClient(jedis, null, null); + assertThrows(FileNotFoundException.class, () -> client.download("missing")); + } +} diff --git a/docs/en/task/model.md b/docs/en/task/model.md index c2f78c345..1137a1684 100644 --- a/docs/en/task/model.md +++ b/docs/en/task/model.md @@ -1,4 +1,3 @@ -model.md # Model This guide introduces the LLM models supported by AgentScope Java and how to configure them. @@ -27,6 +26,86 @@ This guide introduces the LLM models supported by AgentScope Java and how to con | Gemini | [Google AI Studio](https://aistudio.google.com/apikey) | `GEMINI_API_KEY` | | DeepSeek | [DeepSeek Platform](https://platform.deepseek.com/api_keys) | - | +## ModelRegistry + +[`ModelRegistry`](https://github.com/agentscope-ai/agentscope-java/blob/main/agentscope-core/src/main/java/io/agentscope/core/model/ModelRegistry.java) (`io.agentscope.core.model.ModelRegistry`) resolves a `Model` from a **string id**, so you do not have to call each vendor’s `*ChatModel.builder()` for simple setups. With Harness, use `HarnessAgent.builder().model(String)`; anywhere else that needs a `Model`, call `ModelRegistry.resolve(...)` and pass the result into `ReActAgent` or other builders. + +### API summary + +| Method | Description | +|--------|-------------| +| `register(String name, Model model)` | Registers a **named** model; `resolve(name)` returns that instance. | +| `registerFactory(String regex, ModelFactory factory)` | Registers a custom factory for ids matching the regex; **later** registrations take precedence over earlier user factories and over built-in rules. | +| `resolve(String modelId)` | Returns a `Model`; throws `IllegalArgumentException` if the id cannot be resolved or creation fails. | +| `canResolve(String modelId)` | Returns whether the id can be resolved (does not create a model). | +| `reset()` | Clears named registrations, user factories, and the resolve cache; built-in rules stay. Intended for tests or in-process reset. | + +`ModelFactory` is a functional interface: `Model create(String modelId)` with the full id string. + +### Built-in id formats and environment variables + +With the right environment variables set, you can use these id forms (with `resolve` or `HarnessAgent.Builder.model(String)`, for example): + +| Example id | Environment variable | Notes | +|------------|----------------------|-------| +| `openai:gpt-4o-mini` | `OPENAI_API_KEY` | OpenAI-compatible HTTP model | +| `dashscope:qwen-max` | `DASHSCOPE_API_KEY` | Alibaba DashScope / Bailian | +| Any id starting with `qwen-`, e.g. `qwen-max` | `DASHSCOPE_API_KEY` | Uses the whole string as the DashScope `modelName` | +| `anthropic:claude-sonnet-4-5-20250929` | `ANTHROPIC_API_KEY` (optional; SDK may read from the environment) | Anthropic Claude | +| `gemini:gemini-2.5-flash` | `GEMINI_API_KEY` | Google Gemini API | +| `ollama:llama3` | `OLLAMA_BASE_URL` (optional, default `http://localhost:11434`) | Local Ollama | + +Within one process, repeated `resolve` of the **same** factory-based id returns a **cached** `Model` instance. **Named** registrations are not cached that way. + +### Example: named registration (reuse a tuned model) + +Build once with full control, then register under a name: + +```java +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ModelRegistry; +import io.agentscope.core.model.OpenAIChatModel; +import io.agentscope.harness.agent.HarnessAgent; + +Model tuned = OpenAIChatModel.builder() + .apiKey(System.getenv("OPENAI_API_KEY")) + .modelName("gpt-4o") + .generateOptions(GenerateOptions.builder().temperature(0.2).build()) + .build(); +ModelRegistry.register("my-gpt4o", tuned); + +HarnessAgent agent = HarnessAgent.builder() + .name("demo") + .model("my-gpt4o") + .workspace(workspace) + .build(); +``` + +### Example: built-in prefix (default connection settings) + +```java +import io.agentscope.harness.agent.HarnessAgent; + +HarnessAgent agent = HarnessAgent.builder() + .name("demo") + .model("dashscope:qwen-max") + .workspace(workspace) + .build(); +``` + +### Example: custom factory + +```java +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelRegistry; + +ModelRegistry.registerFactory( + "my-llm:.+", + id -> myModelFactory(id.substring("my-llm:".length()))); + +Model m = ModelRegistry.resolve("my-llm:prod"); +``` + ## DashScope Alibaba Cloud LLM platform, providing Qwen series models. diff --git a/docs/zh/harness/architecture.md b/docs/zh/harness/architecture.md new file mode 100644 index 000000000..6167b1259 --- /dev/null +++ b/docs/zh/harness/architecture.md @@ -0,0 +1,363 @@ +# Harness 架构 + +[Overview](./overview.md) 把 harness 的能力按"解决了什么问题"组织。本文换一个视角:把每个组件的**定义、行为、触发时机、协作对象**讲清楚,最后用时序图说明这些组件在一次 `call()` 里如何协同。 + +> 本文聚焦使用者视角的中粒度——讲清"是谁、什么时候、做什么、跟谁协作",不展开调用栈与实现细节;那些放在各子文档([memory](./memory.md)、[workspace](./workspace.md)、[filesystem](./filesystem.md)、[sandbox](./sandbox.md)、[subagent](./subagent.md)、[session](./session.md)、[tool](./tool.md))。 + +## 1. 顶层结构 + +`HarnessAgent` 不是一个新的推理循环,它是 `Agent` + `StateModule` 的薄包装,内部持有一个 `ReActAgent delegate`,所有 `call` / `stream` / `observe` / `saveTo` / `loadFrom` 都转发过去。所有 harness 能力都通过 `ReActAgent` 已有的三个扩展点装配: + +```mermaid +flowchart TD + HA["HarnessAgent
    Agent + StateModule,对外 API"] + HA --> SHARED["共享对象"] + HA --> DEL["delegate: ReActAgent"] + + SHARED --> WM["WorkspaceManager
    谁来读写工作区"] + SHARED --> AFS["AbstractFilesystem
    本地 / 沙箱 / 远端 / 组合"] + SHARED --> RC["RuntimeContext
    sessionId / userId / session / extra"] + + DEL --> HK["hooks
    按 priority 排序"] + DEL --> TK["toolkit
    用户 + harness 内置 + SubagentsHook 注入的 task 工具"] + DEL --> SB["skillBox
    AgentSkillRepository 或 workspace/skills/"] + DEL --> MEM["memory
    InMemoryMemory(通过 Session 持久化)"] +``` + +**注入发生在 `HarnessAgent.Builder.build()`**:构造完三个共享对象后,按固定顺序串好 hook 列表、向用户 toolkit 追加内置工具、根据工作区/repo 装配 skillBox,最后交给 `ReActAgent.builder()`。 + +每次 `agent.call(msg, ctx)` 开头由 `HarnessAgent.bindRuntimeContext(ctx)` 把当次的 `RuntimeContext` 分发给所有实现了 `RuntimeContextAwareHook` 的 hook(workspace context、memory flush、compaction、session persistence),并按需从 `Session` 自动恢复状态。 + +## 2. 三个共享对象 + +这三个对象是 hook 之间协作的通用语言。理解它们就理解了 harness 的"耦合方式"。 + +### 2.1 RuntimeContext + +当次 `call()` 的身份载体:不持久化,每次 `call` 重新分发给 `RuntimeContextAwareHook`。 + +- **`sessionId`** —— 决定持久化路径、JSONL 文件名 +- **`userId`** —— 透传 `AbstractFilesystem.NamespaceFactory` 实现多租户隔离 +- **`session` + `sessionKey`** —— 显式指定或默认 `WorkspaceSession + SimpleSessionKey.of(sessionId)` +- **`extra`** —— 自定义键值,工具/hook 通过 `ctx.get(key)` 读取 + +### 2.2 WorkspaceManager + +工作区无状态访问器。**两层语义**:读优先 filesystem 命中、否则回退本地;写一律走 filesystem;列表合并去重。期望布局: + +``` +workspace/ +├── AGENTS.md / MEMORY.md +├── memory/YYYY-MM-DD.md / .consolidation_state / archive/ +├── memory_index.db # SQLite FTS5 +├── knowledge/KNOWLEDGE.md / **/* +├── skills//SKILL.md +├── subagents/*.md # YAML front matter + body +└── agents// + ├── context//{key}.json # WorkspaceSession 写入 + └── sessions/.log.jsonl # MemoryFlushManager 卸载 +``` + +### 2.3 AbstractFilesystem + +工作区的物理后端,可插拔。基础接口 `ls/read/write/edit/grep/glob/upload/download`;继承接口 `AbstractSandboxFilesystem` 追加 `execute/id`。 + +| 实现 | 用途 | 关键特性 | +|---|---|---| +| `LocalFilesystem` | 本地磁盘 | `virtualMode` 锚定 `rootDir` 阻止穿越;无 shell | +| `LocalFilesystemWithShell` | 本地 + 宿主 shell | 声明式下对应 `LocalFilesystemSpec` 与**无 `filesystem` 的默认**;`instanceof AbstractSandboxFilesystem` 时注册 `shell_execute` | +| `BaseSandboxFilesystem` / `SandboxBackedFilesystem` | 沙箱后端 | 文件与命令在沙箱内;见 [Sandbox](./sandbox.md) | +| `RemoteFilesystem` | KV store | 在 `RemoteFilesystemSpec` 下与 `LocalFilesystem` 经 `CompositeFilesystem` 路由;无 shell | +| `CompositeFilesystem` | 按前缀路由 | 仅实现 `AbstractFilesystem`(**不**实现 `AbstractSandboxFilesystem`),**不**触发 `ShellExecuteTool`;最长前缀优先 | + +> **多租户与隔离**:`NamespaceFactory` 在每次操作时被调用;`RemoteFilesystemSpec` / `SandboxFilesystemSpec` 还可配 `IsolationScope`(与沙箱/共享存储命名一致)。**三种声明式模式**以何者注册 `ShellExecuteTool` 为准,见 [filesystem](./filesystem.md#三种声明式模式)。 + +## 3. Hook 列表 + +下列为 `Builder.build()` 中常见的 harness 内置 hook(**沙箱模式**下会加入 `SandboxLifecycleHook`,见 [Sandbox](./sandbox.md))。`ReActAgent` 按 `priority()` **升序**执行,同优先级时保留装配顺序。 + +| Hook | 优先级 | 监听事件 | 默认开启 | 关键依赖 | +|------|--------|----------|---------|----------| +| `AgentTraceHook` | 0 | 全部 | ✓(默认;可 `.agentTracing(false)` 关闭)| — | +| `MemoryFlushHook` | 5 | `PostCallEvent` | ✓(需 `model`)| `WorkspaceManager`、`Model`、`MemoryFlushManager` | +| `MemoryMaintenanceHook` | 6 | `PostCallEvent`(有节流) | ✓(需 `model`)| `MemoryConsolidator`、`WorkspaceManager` | +| `CompactionHook` | 10 | `PreReasoningEvent` | ✗(需显式 `.compaction(...)`)| `WorkspaceManager`、`Model`、`CompactionConfig`、`MemoryFlushManager` | +| `SandboxLifecycleHook` | 50 | `PreCall` / `PostCall` / `Error` | 仅当 `filesystem(SandboxFilesystemSpec)` | `SandboxManager`、`SandboxBackedFilesystem` | +| `ToolResultEvictionHook` | 50 | `PostActingEvent` | ✗(需显式 `.toolResultEviction(...)`)| `AbstractFilesystem`、`ToolResultEvictionConfig` | +| `SubagentsHook` | 80 | `PreReasoningEvent` + `tools()` | ✓(非 leaf 且有 `model`)| 子 agent 列表、`TaskRepository` | +| `WorkspaceContextHook` | 900 | `PreReasoningEvent` | ✓ | `WorkspaceManager`、`RuntimeContext`、token 预算 | +| `SessionPersistenceHook` | 900 | `PostCallEvent` + `ErrorEvent` | ✓ | `RuntimeContext` | + +> 实现 `RuntimeContextAwareHook` 接口的 hook(workspace context、memory flush、compaction、session persistence)会在每次 `call()` 通过 `bindRuntimeContext` 被重新注入当次的 `RuntimeContext`。 + +下面分组详解每个 hook 在它的事件回调里**做了什么**。 + +### 3.1 上下文注入:`WorkspaceContextHook`(priority 900) + +**作用**:每轮推理前把工作区文件以 `` XML 块合并进第一条 SYSTEM 消息。 + +**触发**:`PreReasoningEvent`。优先级 900 让它跑在压缩、子 agent 之后,叠加在最终 system prompt 上。 + +**关键逻辑**:读 AGENTS / MEMORY / KNOWLEDGE(含目录列表)+ 用户指定的 `additionalContextFiles` → 估 token(chars/4)后按 `maxContextTokens` 预算保留固定区段、剩余给 `MEMORY.md` 并在超额时尾部截断 + 提示 `memory_search`。 + +### 3.2 记忆管理:`MemoryFlushHook` + 后台 + +**作用**:`MemoryFlushHook`(priority 5)在 `PostCallEvent` 把当前 memory 全量交给 `MemoryFlushManager`,做两件事: + +- **flushMemories**:LLM 提炼事实 → append 到 `memory/YYYY-MM-DD.md`(日流水账)→ 增量更新 FTS5 +- **offloadMessages**:原始消息序列写到 `agents//sessions/.log.jsonl` + +整体由四个组件分工: + +| 组件 | 负责 | 频率 | +|---|---|---| +| `MemoryFlushManager` | 第一层:日流水账 + JSONL | 每次 `call()` 末尾 + 每次压缩前 | +| `MemoryConsolidator` | 第二层:curated `MEMORY.md` | 6 小时周期 / opportunistic(30min 节流) | +| `MemoryIndex` | SQLite FTS5 索引 `memory_index.db` | 增量(写入时)+ 全量(维护周期) | +| `MemoryMaintenanceScheduler` | 调度 + 旧文件归档/清理 | 守护线程 6 小时周期 | + +> **双层语义**:日流水账只 append、永不修改;`MEMORY.md` 由 consolidator 整体重写(输出完整新版本,不是 diff)。第一层是事实流,第二层是 curated 视图。与 `CompactionHook` 不重叠:压缩管被压缩的 prefix,本 hook 管保留尾部。 + +### 3.3 上下文长度控制:`CompactionHook` + 溢出兜底 + +**作用**:`CompactionHook`(priority 10)在 `PreReasoningEvent` 委托 `ConversationCompactor.compactIfNeeded` 压缩对话。 + +**触发条件**:消息数 ≥ `triggerMessages` 或 token 数 ≥ `triggerTokens`(默认 50 / 80K)。 + +**触发后**:先 `flushMemories(prefix)` 提炼事实、`offloadMessages(整段)` 卸载 JSONL,再用结构化 prompt(SESSION INTENT / SUMMARY / ARTIFACTS / NEXT STEPS)让 LLM distill 成 summary,得到 `[summaryMsg + tail]` 同时写回 `Memory` 与 `event.setInputMessages`。`tail` 长度由 `keepMessages` / `keepTokens` 控制(默认 20 条)。 + +**溢出兜底**:`HarnessAgent.call()` 捕获模型 `ContextOverflow` 类异常 → `forceCompactAndRetry` 强制最激进压缩 → 重试一次 `delegate.call()`。这是阈值配置不当时的最后防线。 + +### 3.4 工具结果卸载:`ToolResultEvictionHook`(priority 50) + +**作用**:单条工具结果太大时落盘,上下文里只留 head+tail 预览 + 占位符。 + +**触发**:`PostActingEvent`(先于 memory 写入,下游只看到占位符)。 + +**关键逻辑**:超过 `maxResultChars`(默认 80K chars ≈ 20K tokens)→ 写到 `{evictionPath}/{agent}/{toolCallId}` → 用 `Tool output too large, saved to ...` + head 2K + tail 2K 替换。`excludedToolNames`(read/write/edit、grep/glob/ls、memory/session 搜索)跳过卸载——这些工具自带分页或回读会循环。 + +> 与压缩独立:压缩管深度(消息累计长度),卸载管宽度(单条消息长度)。 + +### 3.5 会话持久化:`SessionPersistenceHook` + `WorkspaceSession` + +**作用**:`SessionPersistenceHook`(priority 900)在 `PostCallEvent` 与 `ErrorEvent` 都尝试 `agent.saveTo(session, sessionKey)`(`HarnessAgent` 实现 `StateModule`)。优先级 900 让 `MemoryFlushHook` (5) 先把记忆写完再快照。 + +**`WorkspaceSession`** 是 `JsonSession` 子类,把 baseDir 锁到 `/agents//context/`,最终落盘 `/agents//context//{key}.json`。 + +下次 `call()` 开头 `bindRuntimeContext` 调 `loadIfExists` 还原 memory——这就是"同一 sessionId 跨调用记忆"的来源。 + +### 3.6 子 agent 编排:`SubagentsHook` + `TaskRepository` + +**作用**:`SubagentsHook`(priority 80)双角色——通过 `tools()` 注册 `agent_spawn / agent_send / agent_list / task_output / task_cancel / task_list`,并在 `PreReasoningEvent` 注入子 agent 名+描述列表的 system prompt 段。 + +- **同步路径** `agent_send`:阻塞执行子 agent 并回填结果 +- **后台路径** `agent_spawn`:通过 `TaskRepository.putTask` 提交到 executor 拿 `taskId`;父 agent 后续轮用 `task_output(taskId)` 拉结果 + +**子 agent 来源**(`Builder.buildSubagentEntries`):工作区 `subagents/*.md`(`AgentSpecLoader` 解析)/ 编程式 `.subagent(spec)` / 自定义 `.subagentFactory`。每个子 agent 默认是个 leaf `HarnessAgent`(共享父 agent 的 workspace/filesystem/model 但不再装 `SubagentsHook`)。 + +**`TaskRepository`** 是任务编排接口(`putTask` / `getTask` / `listTasks(filter)` / `cancelTask`);默认 `DefaultTaskRepository` 内部用线程池 + `CompletableFuture` + `BackgroundTask` 包装状态机(PENDING/RUNNING/COMPLETED/FAILED/CANCELLED)。 + +### 3.7 追踪日志:`AgentTraceHook`(priority 0) + +监听全部事件,输出 `[] PRE_REASONING | model=..., messages=...` 风格 INFO 日志(DEBUG 打详细内容);不修改事件。 + +## 4. `call()` 生命周期时序 + +下图展示一次完整 `agent.call(msg, ctx)` 中各组件的协作顺序。**hook 在同一事件上按 priority 升序触发**——这就是它们能彼此叠加而不打架的原因。 + +```mermaid +sequenceDiagram + autonumber + actor User + participant HA as HarnessAgent + participant RA as delegate
    (ReActAgent) + participant H as Hooks
    (按 priority) + participant M as Model + participant T as Toolkit + participant FS as Filesystem
    + Memory/JSONL + + User->>HA: call(msg, ctx) + HA->>HA: bindRuntimeContext(ctx) + Note over HA: 分发给 RuntimeContextAwareHook
    loadIfExists(session, key) 恢复状态 + HA->>RA: delegate.call(...) + + RA->>H: PreCallEvent + Note over H: AgentTraceHook(0) 记日志 + + loop ReAct 推理循环 + RA->>H: PreReasoningEvent + Note over H,FS: CompactionHook(10) 阈值达到则:
    ① flushMemories(prefix) → memory/*.md
    ② offload(整段) → sessions/*.jsonl
    ③ LLM distill prefix → summaryMsg
    ④ memory.clear() + add(summary + tail)
    ⑤ event.setInputMessages(...)

    SubagentsHook(80) 注入子 agent prompt
    WorkspaceContextHook(900) 注入 workspace 文件 + RA->>M: Model.stream(messages) + M-->>RA: ChatResponse + RA->>H: PostReasoningEvent + Note over H: AgentTraceHook(0) 记 text 或 tool_calls + + alt 含 tool_calls + loop 每个 tool_call + RA->>H: PreActingEvent + RA->>T: Toolkit.invoke(toolCall) + T-->>RA: ToolResult + RA->>H: PostActingEvent + Note over H,FS: ToolResultEvictionHook(50) 超阈值则:
    ① 写到 filesystem evictionPath
    ② head+tail 占位符替换 result + end + else 无 tool_call (文本响应) + Note over RA: 跳出循环 + end + end + + RA->>H: PostCallEvent + Note over H,FS: MemoryFlushHook(5):
    ① flushMemories → memory/YYYY-MM-DD.md
    ② offloadMessages → sessions/*.jsonl
    ③ requestConsolidation 通知后台

    SessionPersistenceHook(900) saveTo(session, key) + RA-->>HA: final Msg + HA-->>User: final Msg + + Note over HA: 失败路径: throw → ErrorEvent → SessionPersistenceHook 仍然 saveTo
    ContextOverflow → forceCompactAndRetry → delegate.call 重试 +``` + +## 5. 后台维护时序 + +`MemoryMaintenanceScheduler.start()` 在 `Builder.build()` 末尾被触发;它持有一个守护线程的 `ScheduledExecutorService`。 + +```mermaid +flowchart LR + subgraph CYCLE["周期触发(默认每 6 小时)"] + direction TB + T0["runMaintenance()"] --> S1["1. expireDailyFiles
    memory/*.md 文件名 YYYY-MM-DD
    且 < cutoff(默认 90 天)
    → archive/"] + S1 --> S2["2. consolidateMemory
    读 .consolidation_state 水位
    列出 mtime > 水位的日文件
    LLM 合并 → 覆盖 MEMORY.md → 推进水位"] + S2 --> S3["3. pruneOldSessions
    workspace/agents/<id>/sessions/*
    mtime < cutoff(默认 180 天)的非 sessions.json 删除"] + S3 --> S4["4. reindex
    MemoryIndex.indexAllFromWorkspace
    DELETE + INSERT 重建 FTS5"] + end + + subgraph OPP["opportunistic 路径"] + direction TB + F["MemoryFlushManager.flushMemories()
    每次 flush 成功"] --> R["scheduler.requestConsolidation()"] + R -- "距上次 < 30min" --> SK["跳过"] + R -- "否则" --> SUB["submit consolidateMemory
    (走 executor,不阻塞 agent)"] + end +``` + +## 6. 几个典型协作场景 + +最后用四个具体路径把组件串起来,看它们是怎么真正协作的。 + +### 场景 A — 工作区文件变成模型看到的 system prompt + +```mermaid +sequenceDiagram + participant RA as ReActAgent + participant Hook as WorkspaceContextHook
    (priority 900) + participant WM as WorkspaceManager + participant FS as AbstractFilesystem + participant LD as 本地磁盘 + participant M as Model + + RA->>Hook: PreReasoningEvent(messages) + Note over WM: readAgentsMd / readMemoryMd / readKnowledgeMd + Hook->>WM: 读取工作区文件 + WM->>FS: read(...) 优先 + alt FS 命中非空 + FS-->>WM: 内容(多租户透明) + else 否则 + WM->>LD: Files.readString(workspace/AGENTS.md) + LD-->>WM: 内容(兜底) + end + WM-->>Hook: AGENTS / MEMORY / KNOWLEDGE / extra + Note over Hook: 包入 loaded_context XML 块
    合并到第一条 SYSTEM 消息
    event.setInputMessages(...) + Hook-->>RA: 返回修改后的 event(含新 messages) + RA->>M: Model.stream(messages) +``` + +### 场景 B — 长会话里事实如何沉淀进 `MEMORY.md` + +```mermaid +flowchart TD + A["对话累积 → 触发 CompactionHook 阈值"] --> B["ConversationCompactor.compactIfNeeded"] + B --> C["MemoryFlushManager
    .flushMemories(prefix)"] + B --> D["offloadMessages
    → sessions/<sessionId>.log.jsonl"] + B --> E["distill summary
    → 替换 memory + setInputMessages"] + + C --> C1["memory/YYYY-MM-DD.md (append)"] + C --> C2["MemoryIndex.indexFromString
    (FTS5 增量)"] + C --> C3["scheduler.requestConsolidation"] + + C3 -- "throttle 30min" --> C4["submit consolidateMemory"] + C4 --> C5["MemoryConsolidator + LLM"] + C5 --> C6["覆盖 MEMORY.md"] + C6 --> C7["下一次 reindex 反映到 FTS5"] + + C7 --> NEXT["下一次 call"] + NEXT --> N1["WorkspaceContextHook 读 MEMORY.md
    → 注入 system prompt"] + NEXT --> N2["memory_search 工具走 FTS5
    查得旧事实"] +``` + +### 场景 C — 第二轮 `call` 如何"想起"第一轮 + +```mermaid +flowchart LR + subgraph T1["turn 1"] + direction TB + T1A["call(msg1, ctx{sess=A})"] --> T1B["bindRuntimeContext"] + T1B --> T1C["ReAct 循环"] + T1C --> T1D["PostCallEvent"] + T1D --> T1E["MemoryFlushHook
    flush + offload"] + T1D --> T1F["SessionPersistenceHook
    saveTo(session, key)
    → 写盘"] + end + + subgraph T2["turn 2"] + direction TB + T2A["call(msg2, ctx{sess=A})"] --> T2B["bindRuntimeContext"] + T2B --> T2B1["loadIfExists
    读 context/A/{key}.json
    memory 恢复 turn1 对话"] + T2B1 --> T2C["ReAct 循环
    (内存已含 turn1)"] + T2C --> T2D["PostCallEvent"] + T2D --> T2E["..."] + T2D --> T2F["SessionPersistenceHook
    saveTo (覆盖) → 写盘"] + end + + T1F -. 持久化到磁盘 .-> T2B1 +``` + +### 场景 D — 子 agent 的同步与后台两条委派路径 + +```mermaid +sequenceDiagram + participant Parent as 父 agent + participant Hook as SubagentsHook + participant Sub as 子 HarnessAgent (leaf) + participant Repo as TaskRepository + participant Exec as Executor + + Note over Parent: reasoning 选了 SubagentsHook 注入的工具 + + rect rgb(238, 248, 255) + Note over Parent,Sub: 同步路径 + Parent->>Hook: agent_send(name, message) + Hook->>Sub: factory.create() + Hook->>Sub: sub.call(msg).block() + Sub-->>Hook: reply + Hook-->>Parent: ToolResultBlock(reply) + end + + rect rgb(255, 245, 238) + Note over Parent,Exec: 后台路径 + Parent->>Hook: agent_spawn(name, message) + Hook->>Repo: putTask(id, name, supplier) + Repo->>Exec: submit(supplier) + Repo-->>Hook: taskId + Hook-->>Parent: ToolResultBlock(taskId) + + Note over Parent: 父 agent 后续轮 + Parent->>Hook: task_output(taskId) + Hook->>Repo: getTask(taskId).getResult() + Repo-->>Hook: result / null + Hook-->>Parent: ToolResultBlock(result/状态) + end +``` + +## 延伸阅读 + +- [Workspace](./workspace.md) — 工作区目录结构、`WorkspaceManager` 两层读取细节 +- [Memory](./memory.md) — 双层记忆模型、压缩配置、FTS5 检索、消息格式细节 +- [Filesystem](./filesystem.md) — `AbstractFilesystem` 各实现的取舍与组合方式 +- [Subagent](./subagent.md) — 子 agent 规格格式、`TaskRepository` 自定义、嵌套 harness 的注意事项 +- [Session](./session.md) — `WorkspaceSession` / `JsonSession` 的序列化协议与版本兼容 +- [Tool](./tool.md) — 内置工具参考与自定义工具的注册方式 +- [Roadmap](./roadmap.md) — 已识别的设计权衡与待改进项 diff --git a/docs/zh/harness/filesystem.md b/docs/zh/harness/filesystem.md new file mode 100644 index 000000000..f5149fe87 --- /dev/null +++ b/docs/zh/harness/filesystem.md @@ -0,0 +1,153 @@ +# 文件系统(Filesystem) + +## 作用 + +`AbstractFilesystem` 把 agent 对**工作区**的访问从「一定是本机磁盘」抽象成统一接口:`ls / read / write / edit / grep / glob / upload / download`。需要**在隔离环境执行命令**时,后端再实现 `AbstractSandboxFilesystem`,`HarnessAgent` 才会注册 `ShellExecuteTool`。 + +在 harness 里,**文件系统承担三种不同但常混为一谈的职责**: + +1. **工具面**:`FilesystemTool`(及可选的 `ShellExecuteTool`)只认一个 `AbstractFilesystem` 实例;所有路径与执行都经此出口,便于替换实现。 +2. **工作区读写的物理落点**:`WorkspaceManager` 读时「优先走 filesystem、未命中再回退本地」;写与上传一律走 filesystem。因此**长期记忆、日流水账、会话日志**等最终落在哪个介质上,由你选的 **模式** 决定。 +3. **多租户与隔离**:`NamespaceFactory` 在每次操作中从 `RuntimeContext.userId` 等来源拼出路径前缀,使同一套代码在**用户 / 会话 / 全局**之间切换存储分片;`RemoteFilesystemSpec` 与 `SandboxFilesystemSpec` 还把 **IsolationScope** 接到「共享 KV」或「沙箱状态键」上,与 [Sandbox](./sandbox.md) 的隔离叙事一致。 + +## 三种声明式模式 + +`HarnessAgent.Builder` 在 **`filesystem(...)` 系列** 中**至多选一**(与 **`abstractFilesystem(...)` 互斥**;后者为自带实现的逃生口,见下节): + +| 模式 | 配置方法 | 典型产物 | Shell | 适用场景 | +|------|----------|----------|-------|----------| +| **1 — 复合 + 共享存储** | `filesystem(RemoteFilesystemSpec)` | `CompositeFilesystem`:工作区根上 **无 shell 的** `LocalFilesystem` + 按前缀路由的 `RemoteFilesystem` | 否 | 多副本要共享 `MEMORY.md`、`memory/`、会话落盘等;**不在宿主执行**不受信 shell | +| **2 — 沙箱** | `filesystem(SandboxFilesystemSpec)` | `SandboxBackedFilesystem` + 生命周期由 [Sandbox](./sandbox.md) 描述 | 是(在沙箱内) | 隔离执行、可恢复沙箱会话、可选快照与分布式 Session | +| **3 — 本机 + shell** | `filesystem(LocalFilesystemSpec)` 或**不显式配 filesystem** | `LocalFilesystemWithShell` | 是(宿主 `sh -c`) | 单进程/本机、信任环境、简单脚本与测试 | + +**默认未调用任何 `filesystem(...)` 时** 与 **显式 `filesystem(new LocalFilesystemSpec())`** 等价,即模式 3,根目录为 `workspace`、在宿主上提供 shell。 + +### 模式一:复合 + 存储(`RemoteFilesystemSpec`) + +- **结构**:`RemoteFilesystemSpec#toFilesystem` 组合出 `CompositeFilesystem`: + - **默认/未匹配的前缀** → 纯 `LocalFilesystem`(**无** `ShellExecuteTool`); + - **配置的前缀**(如默认的 `MEMORY.md`、`memory/`、`agents//sessions/` 等 + 可 `addSharedPrefix`)→ `RemoteFilesystem`(`BaseStore` 之上,由 `IsolationScope` 控制命名空间:SESSION / USER / AGENT / GLOBAL)。 +- **为何默认不用 `LocalFilesystemWithShell`**:模式 1 的设计目标是**跨节点一致的长记忆与日志**,同时**避免在宿主上开放 shell**;需要 shell 时请用模式 2 或 3。 + +### 模式二:沙箱(`SandboxFilesystemSpec`) + +- 见 [沙箱(Sandbox)](./sandbox.md)。要点:对外仍是 `AbstractFilesystem` + 可选 `ShellExecuteTool`(经 `AbstractSandboxFilesystem`),但真实 IO/进程在 `SandboxClient` 侧;`SandboxLifecycleHook` 在每次 `call` 周围 acquire/persist/release。 + +### 模式三:本机 + shell(`LocalFilesystemSpec` 或默认) + +- **行为**:`LocalFilesystemWithShell` 根目录为工作区,命令为宿主上的 `sh -c`(可配超时、环境变量、`virtualMode` 等),**与模式 1 的「无 shell 本地根」有本质区别**。 + +## 类层次与 `ShellExecuteTool` 注册 + +```mermaid +classDiagram + class AbstractFilesystem { + <> + ls/read/write/edit + grep/glob + uploadFiles/downloadFiles + } + class AbstractSandboxFilesystem { + <> + +id() String + +execute(cmd, timeout) + } + AbstractSandboxFilesystem --|> AbstractFilesystem + + class LocalFilesystem + class LocalFilesystemWithShell + class BaseSandboxFilesystem + class RemoteFilesystem + class CompositeFilesystem + class SandboxBackedFilesystem + + LocalFilesystem ..|> AbstractFilesystem + RemoteFilesystem ..|> AbstractFilesystem + LocalFilesystemWithShell --|> LocalFilesystem + LocalFilesystemWithShell ..|> AbstractSandboxFilesystem + BaseSandboxFilesystem ..|> AbstractSandboxFilesystem + CompositeFilesystem ..|> AbstractFilesystem + SandboxBackedFilesystem ..|> AbstractSandboxFilesystem +``` + +- **`CompositeFilesystem` 只实现 `AbstractFilesystem`**,不实现 `AbstractSandboxFilesystem`,因此**不会**注册 `ShellExecuteTool`;若需组合路由且又要 shell,需自行用 `abstractFilesystem` 提供含 shell 的默认后端或选用沙箱/本机模式。 +- **`read(filePath, offset, limit)`** 中 `limit <= 0` 表示使用实现定义的「读默认行数」(本地与沙箱可能不同)。 + +## 各实现速查 + +| 实现 | 说明 | +|------|------| +| `LocalFilesystem` | 仅本机文件,无执行;`virtualMode` 锚定 `rootDir` 防穿越 | +| `LocalFilesystemWithShell` | 本机 + 宿主 shell;**模式 3** 的核心 | +| `BaseSandboxFilesystem` | 对接远程 Unix 的基类,多数方法用 `execute` 拼命令实现 | +| `RemoteFilesystem` | 基于 `BaseStore` 的 KV 存储;无 shell;与 `IsolationScope` 搭配 | +| `CompositeFilesystem` | 最长前缀匹配多后端;**不**提供 shell 能力 | +| `SandboxBackedFilesystem` | 沙箱代理,实现 `AbstractSandboxFilesystem`;与 `SandboxManager` 配合 | + +## `BaseSandboxFilesystem` 的默认实现策略 + +子类主要实现 `execute / uploadFiles / downloadFiles / id` 时,基类常把 `ls/read/grep/glob/edit/write` 转为远程 shell 与 Python3 片段(与旧版 `filesystem.md` 描述一致),便于在标准 Unix 环境快速落地。 + +## `NamespaceFactory` 与多租户 + +```java +@FunctionalInterface +public interface NamespaceFactory { List getNamespace(); } +``` + +每次文件操作会调用,返回当前请求的路径段(如 `["users", "alice"]`)。`HarnessAgent` 构建时可用 `AtomicReference` 与 `RuntimeContext.userId` 联动,使同一份 `AbstractFilesystem` 实例在不同用户下落在不同子树。 + +## 配置示例 + +**推荐:先选三种模式之一,再仅在需要时接触 `abstractFilesystem`:** + +```java +// 模式 3:显式本机 + shell(与「不写 filesystem」默认等价,仅用于调整超时等) +HarnessAgent agent = HarnessAgent.builder() + .name("local") + .model(model) + .workspace(workspace) + .filesystem(new LocalFilesystemSpec().executeTimeoutSeconds(120)) + .build(); +``` + +```java +// 模式 1:共享长期记忆到 Store(无宿主 shell) +HarnessAgent agent = HarnessAgent.builder() + .name("store") + .model(model) + .workspace(workspace) + .filesystem(new RemoteFilesystemSpec(redisStore) + .isolationScope(IsolationScope.USER)) + .build(); +``` + +```java +// 模式 2:沙箱(具体 spec 因实现类而异,如 Docker) +HarnessAgent agent = HarnessAgent.builder() + .name("sandbox") + .model(model) + .workspace(workspace) + .filesystem(dockerFilesystemSpec) // extends SandboxFilesystemSpec + .build(); +``` + +**逃生口(与上述 `filesystem(…Spec)` 互斥):** + +```java +HarnessAgent agent = HarnessAgent.builder() + .name("custom") + .model(model) + .workspace(workspace) + .abstractFilesystem(myCustomTree) // 完全自管的一棵 AbstractFilesystem + .build(); +``` + +**手动组合(高级)**:在 `abstractFilesystem` 或自建工厂中仍可使用 `CompositeFilesystem` + `LocalFilesystemWithShell` 等,但需自行保证安全边界与 `ShellExecuteTool` 是否应暴露。 + +## 相关文档 + +- [沙箱(Sandbox)](./sandbox.md) — 沙箱模式原理、`SandboxStateStore`、分布式 +- [工具](./tool.md) — `FilesystemTool` / `ShellExecuteTool` 入参 +- [工作区](./workspace.md) — `WorkspaceManager` 与两层读 +- [架构](./architecture.md) — 与 Hook、RuntimeContext 的协作 diff --git a/docs/zh/harness/memory.md b/docs/zh/harness/memory.md new file mode 100644 index 000000000..82bd16cf5 --- /dev/null +++ b/docs/zh/harness/memory.md @@ -0,0 +1,152 @@ +# 记忆(Memory) + +## 作用 + +让 agent 能“记住跨会话的事实”,同时避免对话上下文无限增长。harness 把记忆拆成两层:高频低质量的“流水账” + 低频高质量的“策划后长期记忆”,并补上一套 FTS5 检索 + 后台维护。 + +## 触发 + +| 时机 | 动作 | +|------|------| +| 推理前(`PreReasoningEvent`)| `CompactionHook` 检查对话阈值;超阈则调 `ConversationCompactor` | +| `call()` 结束(`PostCallEvent`)| `MemoryFlushHook` 调 `MemoryFlushManager` 提取记忆 + offload | +| 上下文溢出(`ContextLengthExceeded`)| `HarnessAgent.forceCompactAndRetry` 以 `triggerMessages=1` 强制压缩并重试 | +| 工具返回超大(`PostActingEvent`)| `ToolResultEvictionHook` 卸载到 filesystem | +| 后台调度 | `MemoryMaintenanceScheduler` 默认每 6h 跑一轮;flush 后会“机会主义”传一个信号(30 分钟级节流) | + +## 关键逻辑 + +### 双层记忆模型 + +```mermaid +flowchart LR + Conv[对话 messages] -->|超阈值| Compactor[ConversationCompactor] + Compactor -->|offload| Sess[sessions/<id>.log.jsonl] + Compactor -->|flushMemories| Flush[MemoryFlushManager] + Flush -->|append + index| Daily[memory/YYYY-MM-DD.md] + Daily -. 后台二次加工 .-> Cons[MemoryConsolidator] + MEM[MEMORY.md作为上下文] -->|读取去重| Cons + Cons -->|重写| MEM + MEM -->|每次推理注入| Hook[WorkspaceContextHook] + Daily -.不直接注入.- Hook + Daily --> Idx[(MemoryIndex
    SQLite FTS5)] + MEM --> Idx +``` + +- **第一层·流水账 `memory/YYYY-MM-DD.md`**:`MemoryFlushManager` 专属,**只追加**,不去重;是“刚刚在说什么”的原始记录。 +- **第二层·策划后长期记忆 `MEMORY.md`**:`MemoryConsolidator` 专属,**整体重写**;MemoryFlushManager 不会去动它。每次推理都走 `WorkspaceContextHook` 注入到 system prompt。 +- **索引 `MemoryIndex`**:启动时 `indexAllFromWorkspace` 一次;每次 flush 写今日流水账后增量重建该文件索引;SQLite 文件位于 `/memory_index.db`。 + +### 对话压缩(`ConversationCompactor`) + +``` +检查阈值 → 找 cutoff(不切开 ASSISTANT/TOOL 对) + → (可选) flushMemories(prefix) + → (可选) offloadMessages(messages → sessions/.../.jsonl) + → LLM 提炼 summary + → [summaryUserMsg] + tail 返回给 hook重装 memory +``` + +默认值(全部可调): + +| 参数 | 默认 | 说明 | +|------|------|------| +| `triggerMessages` | `50` | 按条数触发(`0` = 关闭) | +| `triggerTokens` | `80_000` | 按 token 估算触发(`0` = 关闭) | +| `keepMessages` | `20` | 保留尾部条数 | +| `keepTokens` | `0` | 非 0 时按 token 预算从后往前扫描,覆盖 `keepMessages` | +| `flushBeforeCompact` | **`true`** | 压缩前提取记忆到今日流水账 | +| `offloadBeforeCompact` | **`true`** | 压缩前将原始消息追写到会话 `.log.jsonl` | +| `summaryPrompt` | 内置模板 | 包含 `SESSION INTENT / SUMMARY / ARTIFACTS / NEXT STEPS` 四段式 | + +```java +CompactionConfig.builder() + .triggerMessages(30) + .keepMessages(10) + .build(); // flush/offload 默认都是 true +``` + +#### `TruncateArgsConfig`—轻量预处理(可选) + +在 LLM 摘要之前,可以先走一个**不走 LLM** 的干预:对老消息里不那么重要的 `ToolUseBlock` 参数做字符串截断(默认阈值 25 条 / 40k tokens,参数超 2000 字符被裁揉)。适合 `write_file` 这种入参体量大、后期不需要原貌的场景。 + +```java +CompactionConfig.builder() + .triggerMessages(80) + .truncateArgs(TruncateArgsConfig.builder().build()) + .build(); +``` + +#### 上下文溢出自动恢复 + +当模型返回 `context_length_exceeded` / `maximum context` 之类错误,`HarnessAgent.recoverFromOverflow` → `forceCompactAndRetry` 会拼一个临时 `triggerMessages=1` 的 `CompactionConfig` 走一轮压缩,清空 `Memory` 后重试;**前提是配置了 `compaction(...)`**,否则直接抛错。 + +### 记忆提取(`MemoryFlushManager`) + +- `flushMemories(messages)`:拿当前 `MEMORY.md` 和今日流水账作为“去重参考”丢给 LLM,要求输出 **仅新增的** bullet;“NO_REPLY” 表示什么都不写。 +- 写入位置固定是 `memory/YYYY-MM-DD.md`,**不会动 `MEMORY.md`**(以防二层被一层覆写)。 +- 写完后立刻 `indexFromString` 重建该文件索引,并调 `MemoryMaintenanceScheduler.requestConsolidation()` 提示“能合并了合并下”。 + +### 二次合并(`MemoryConsolidator`) + +- 读 mtime 超过 watermark 的日流水账 + 当前 `MEMORY.md`,调 LLM 合并、去重、裁剪。 +- 输出限制:默认 `maxMemoryTokens=4000`(约 16k 字符),prompt 会以字符预算的形式告诉 LLM。 +- 写后推进 watermark,存于 `memory/.consolidation_state`;下次只看 mtime 超 watermark 的日文件。 +- 合并仅在后台 executor 跳:定期 tick 或 `requestConsolidation()` 发起,永不阻塞推理循环。 + +### 后台维护(`MemoryMaintenanceScheduler`) + +`HarnessAgent.build()` 中自动创建并 `start()`,每个 tick 顺序跑: + +1. `expireDailyFiles` — 超过 `dailyFileRetentionDays` 的日文件归档到 `memory/archive/`(**默认 90 天**) +2. `consolidateMemory` — 调 `MemoryConsolidator.consolidate()` +3. `pruneOldSessions` — 删除 mtime 超 `sessionRetentionDays` 的会话文件(**默认 180 天**) +4. `reindex` — `MemoryIndex.indexAllFromWorkspace` + +默认间隔 `Duration.ofHours(6)`;opportunistic 调用实际节流间隔 30 分钟,避免频繁 flush 打爆 LLM。 + +### 工具结果卸载(`ToolResultEvictionConfig`) + +与压缩独立。某次 `tool_call` 返回的文本超过阈值时,全文写到 `evictionPath` 下的文件,原位置只留一个“首尾预览 + 路径”的占位符,agent 需要完整内容时走 `read_file`。 + +| 参数 | 默认 | 说明 | +|------|------|------| +| `maxResultChars` | `80_000` | 超过则卸载 | +| `previewChars` | `2_000` | 首尾预览字符数 | +| `evictionPath` | `/large_tool_results` | 卸载文件根路径 | +| `excludedToolNames` | 内置集(含 `read_file` 等) | 不参与卸载的工具 | + +```java +HarnessAgent.builder() + ... + .toolResultEviction(ToolResultEvictionConfig.defaults()) + .build(); +``` + +## 配置与代码示例 + +```java +HarnessAgent agent = HarnessAgent.builder() + .name("MyAgent") + .model(model) + .workspace(workspace) + .compaction(CompactionConfig.builder() + .triggerMessages(30) + .keepMessages(10) + .build()) + .toolResultEviction(ToolResultEvictionConfig.defaults()) + .build(); + +// agent 可随时调用 memory_search +MemoryIndex index = new MemoryIndex(workspaceAgentScopeDir); +index.open(); +List hits = index.search("数据库迁移", 10); +// hit: { path, lineNumber, content, rank } +``` + +## 相关文档 + +- [工具](./tool.md) — `memory_search` / `memory_get` 的参数与调用例 +- [工作区](./workspace.md) — `MEMORY.md` / `memory/*.md` 在工作区的位置 +- [会话](./session.md) — `.log.jsonl` / `.jsonl` 怎么反过来被记忆提取使用 +- [架构](./architecture.md) — CompactionHook / MemoryFlushHook / ToolResultEvictionHook 在生命周期中的位置 diff --git a/docs/zh/harness/overview.md b/docs/zh/harness/overview.md new file mode 100644 index 000000000..bb7ffc00b --- /dev/null +++ b/docs/zh/harness/overview.md @@ -0,0 +1,163 @@ +# Harness 概览 + +`agentscope-harness` 在 `agentscope-core` 的 `ReActAgent` 之上,通过 Hook 和 Toolkit 两个扩展点,装配出一套面向**长期稳定运行**的工程化基础设施。用户入口只有一个类:`HarnessAgent`。 + +裸的 `ReActAgent` 只有"请求-推理-工具-回复"一轮循环。harness 要回答的是另一组问题:**下一轮怎么办、下一天怎么办、上下文爆了怎么办、状态丢了怎么办、任务太重怎么办**。它不替换推理循环,而是在循环的关键时机插入 hook、为模型补上一组基础工具,把这些问题的默认工程答案打包好。 + +## 快速开始 + +引入依赖: + +```xml + + io.agentscope + agentscope-harness + ${agentscope.version} + +``` + +下面这个示例演示 harness 的三个核心价值:**工作区驱动的人格**、**会话持久化**(同一 `sessionId` 的第二轮对话能记得第一轮的内容)、**显式启用对话压缩**。第一次运行时会在 `${cwd}/.agentscope/workspace/` 自动生成 `AGENTS.md`,之后的运行复用。 + +```java +public class QuickstartExample { + + public static void main(String[] args) throws Exception { + // 1. 准备工作区:第一次运行生成 AGENTS.md,后续运行复用 + Path workspace = Paths.get(".agentscope/workspace"); + initWorkspaceIfAbsent(workspace); + + // 2. 构建模型 + Model model = DashScopeChatModel.builder() + .apiKey(System.getenv("DASHSCOPE_API_KEY")) + .modelName("qwen-max") + .stream(true) + .build(); + + // 3. 构建 HarnessAgent:工作区注入、会话持久化、追踪日志默认开启; + // 这里显式启用对话压缩 + HarnessAgent agent = HarnessAgent.builder() + .name("quickstart-agent") + .sysPrompt("你是一个帮助用户做笔记的助手。") + .model(model) + .workspace(workspace) + .compaction(CompactionConfig.builder() + .triggerMessages(30) + .keepMessages(10) + .flushBeforeCompact(true) // 压缩前把事实提取到日流水账 + .build()) + .build(); + + // 4. 同一个 RuntimeContext 发起两轮对话 + // sessionId 相同 → 第二轮自动从 Session 恢复第一轮的状态 + RuntimeContext ctx = RuntimeContext.builder() + .sessionId("demo-session") + .userId("alice") + .build(); + + Msg turn1 = agent.call( + Msg.builder().role(MsgRole.USER) + .textContent("我叫天宇,今天准备一个关于 ReAct 的技术分享。") + .build(), + ctx).block(); + System.out.println("[turn1] " + turn1.getTextContent()); + + Msg turn2 = agent.call( + Msg.builder().role(MsgRole.USER) + .textContent("我叫什么?我今天要干什么?") + .build(), + ctx).block(); + System.out.println("[turn2] " + turn2.getTextContent()); + } + + 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, """ + # 笔记助手 + + 你是一个帮助用户整理笔记和知识的助手。 + + ## 行为约定 + - 主动记录用户提到的关键事实(姓名、计划、偏好等) + - 回答用简洁中文,必要时给出要点列表 + - 对不确定的内容要主动说明,不要臆造 + """); + } +} +``` + +完整可运行版本:[`agentscope-examples/harness-example/src/main/java/io/agentscope/harness/example/QuickstartExample.java`](../../../agentscope-examples/harness-example/src/main/java/io/agentscope/harness/example/QuickstartExample.java) + +运行: + +```bash +export DASHSCOPE_API_KEY=your_key_here + +# 首次运行需要把依赖模块 install 到本地仓库(跳过 javadoc 与 spotless) +mvn -pl agentscope-examples/harness-example -am install \ + -DskipTests -Dspotless.check.skip=true -Dmaven.javadoc.skip=true -q + +# 执行 main +mvn -pl agentscope-examples/harness-example exec:java \ + -Dexec.mainClass=io.agentscope.harness.example.QuickstartExample \ + -Dspotless.check.skip=true -q +``` + +**运行后观察**: + +- `.agentscope/workspace/AGENTS.md` 被自动创建 —— 这就是 agent 的人格来源 +- 第二轮提问"我叫什么"能答出来,因为同一 `sessionId=demo-session` 的第二次 `call()` 在开头通过 `bindRuntimeContext` 自动从 `Session` 加载了第一轮的状态 +- 多聊几轮触发压缩(消息数 ≥ 30)后,可以在 `workspace/memory/YYYY-MM-DD.md` 看到 LLM 提炼出来的事实流水账;后台的 `MemoryMaintenanceScheduler` 会继续把它合并到 `MEMORY.md` +- 下次重启进程、只要 `sessionId` 不变,agent 依然记得这一切 + +**关于 `RuntimeContext`**:它是当次 `call()` 的身份载体,`sessionId` 决定状态存放与日志归档位置,`userId` 决定默认文件系统的命名空间(天然的多租户隔离)。它**不会被持久化**,只在当次调用的 hook 与工具间共享。 + +**扩展方向**:在工作区里放 `KNOWLEDGE.md`、`skills/*/SKILL.md`、`subagents/*.md` 就能分别开启领域知识注入、技能加载、子 agent 编排;`.toolResultEviction(ToolResultEvictionConfig.defaults())` 一行启用大结果卸载;**文件/命令的落点**用 [Filesystem — 三种声明式模式](./filesystem.md#三种声明式模式) 选择 **共享存储、沙箱或本机+shell**;需隔离执行时优先 `filesystem(SandboxFilesystemSpec)`(见 [Sandbox](./sandbox.md)),`abstractFilesystem` 仅作自管后端的逃生口。 + +## 核心能力 + +每一项能力对应**一个问题 → 一个组件**: + +- **工作区上下文注入** —— 解决 *agent 的身份从哪里来*。每次推理前由 `WorkspaceContextHook` 把 `AGENTS.md`、`MEMORY.md`、今日记忆、`KNOWLEDGE.md` 注入 system prompt。工作区即 agent 的"人格与知识库"。 +- **双层持久记忆** —— 解决 *对话里的事实如何跨会话沉淀*。`MemoryFlushHook` 在压缩前用 LLM 把对话提炼到日流水账;`MemoryConsolidator` 在后台把日流水账合并去重到长期 `MEMORY.md`。下次上线仍然能用。 +- **对话压缩与溢出恢复** —— 解决 *历史太长怎么办*。`CompactionHook` 在消息/Token 超阈值时摘要历史、保留尾部;模型真的报 context overflow 时,`HarnessAgent` 捕获错误、强制压缩、自动重试。 +- **大工具结果卸载** —— 解决 *单次工具返回过大*。`ToolResultEvictionHook` 把超限结果落盘到文件系统,上下文里只留占位符 + 预览,agent 可以按需回读。 +- **会话持久化** —— 解决 *状态如何跨进程保留*。`SessionPersistenceHook` 按 `sessionId` 把 agent 状态写入工作区,下次调用自动从断点恢复。 +- **子 agent 编排** —— 解决 *复杂任务如何分解*。`SubagentsHook` 注入 `task` / `task_output` 工具,主 agent 可同步或后台委派子 agent;子 agent 可由工作区规格文件、编程式 spec、自定义工厂声明。 +- **可插拔文件系统** —— 解决 *agent 的环境如何收敛与隔离*。所有文件工具都走 `AbstractFilesystem`;通过 [三种声明式模式](./filesystem.md#三种声明式模式)(本机+shell、复合+Store、沙箱)或 `abstractFilesystem` 自管;配合 `RuntimeContext.userId` 与 `IsolationScope` 做多租户/会话级隔离。隔离执行与沙箱状态恢复见 [Sandbox](./sandbox.md)。 + +此外还有几项围绕以上能力服务的基础设施:`RuntimeContext` 贯穿整次调用、`MemoryMaintenanceScheduler` 在后台做合并与索引维护、`AgentTraceHook` 统一追踪日志、`AgentSkillRepository` 自动装配 `SkillBox`。 + +## 能力如何共同构成一个稳定运行的 Agent + +把这些能力合起来看,它们其实分别支撑着"持续稳定"的三根支柱: + +- **身份持续** —— *工作区上下文注入* 每轮把人格和知识重新喂给模型;*双层持久记忆* 把对话里有价值的事实沉淀回工作区;*Skill 自动加载* 让可复用能力跟着工作区走。于是 agent 的人格和知识不随单次调用结束而消失,而是在工作区里不断累积。 +- **上下文可控** —— *对话压缩* 控制深度,*工具结果卸载* 控制宽度,*溢出恢复* 是最后一道兜底。三者合在一起保证在任意长度的会话里,上下文都不会把模型压垮;真的压垮了,也能无感恢复。 +- **状态可恢复** —— *会话持久化* 保证进程重启能从断点继续;*RuntimeContext* 把当次调用的身份(sessionId/userId)贯穿到所有 hook 和工具;*可插拔文件系统* 让"状态究竟落在哪里"(本地磁盘、沙箱、远端)变成一个配置选择。 + +这三根支柱之间靠三个共享对象串起来:`WorkspaceManager`(谁来读写工作区)、`AbstractFilesystem`(工作区落在哪里)、`RuntimeContext`(当次调用是谁在说话)。每个 hook 只做自己的事,通过这三个对象和其它 hook 协作——这就是 harness 把一组独立能力合成一个"持续稳定 agent"的方式。 + +## 能力如何注入到 Agent + +`HarnessAgent` 是 `Agent` + `StateModule` 的薄包装,内部持有一个 `ReActAgent delegate`,能力注入全部发生在 `HarnessAgent.Builder.build()`: + +- **Hook 通道**:按 `priority` 把若干 hook 交给 `ReActAgent`(含沙箱模式下的 `SandboxLifecycleHook` 等,详见 [Architecture](./architecture.md)) +- **Toolkit 通道**:在用户 `Toolkit` 上追加 `filesystem`、`memory_search`、`memory_get`、`session_search`,沙箱后端额外加 `shell_execute`;`SubagentsHook` 自己注册 `task` / `task_output` +- **SkillBox 通道**:从 `workspace/skills/` 或自定义 `AgentSkillRepository` 自动构造 `SkillBox` + +每次 `call()` 开头由 `bindRuntimeContext` 把当次的 `RuntimeContext` 分发给所有实现了 `RuntimeContextAwareHook` 的 hook,并按需从 `Session` 恢复状态。 + +> 各组件的详细行为、触发时机和时序图见 [Architecture](./architecture.md)。 + +## 延伸阅读 + +- [Architecture](./architecture.md) — 各组件定义、生命周期时序图、协作关系 +- [工作区(Workspace)](./workspace.md) — 工作区目录结构与上下文注入 +- [记忆(Memory)](./memory.md) — 双层记忆、对话压缩与全文检索 +- [文件系统(Filesystem)](./filesystem.md) — 三种声明式模式与 `AbstractFilesystem` 层次 +- [沙箱(Sandbox)](./sandbox.md) — 隔离执行、沙箱状态与分布式选项 +- [子 Agent(Subagent)](./subagent.md) — 子 agent 规格与编排 +- [工具(Tool)](./tool.md) — 内置工具参考 +- [会话(Session)](./session.md) — 会话持久化与状态恢复 \ No newline at end of file diff --git a/docs/zh/harness/sandbox.md b/docs/zh/harness/sandbox.md new file mode 100644 index 000000000..e99ddbe5c --- /dev/null +++ b/docs/zh/harness/sandbox.md @@ -0,0 +1,302 @@ +# 沙箱(Sandbox) + +[Filesystem](./filesystem.md) 说明了 agent 的「文件与命令」从哪来。当这些操作必须**与宿主进程隔离**、在**可替换的执行环境**(本地 Unix、Docker 等)里完成,并在多次 `call` 之间**恢复同一份工作区状态**时,应选用本文描述的 **沙箱模式**(`filesystem(SandboxFilesystemSpec)`)。 + +## 1. 沙箱解决什么问题 + +- **执行边界**:模型通过同一套 `AbstractFilesystem` / `ShellExecuteTool` 接口操作文件与命令,但**真实 IO 与进程**在沙箱客户端所管理的隔离环境里完成,适合不可完全信任用户输入、或需与生产宿主解耦的场景。 +- **可恢复的工作单元**:与「单次 HTTP 请求」不同,多轮 `call` 应能接续同一逻辑工作区。`SandboxManager` 在每次 `call` 结束时**持久化沙箱侧状态**(通过 `SandboxStateStore`),下次 `acquire` 时按 `IsolationScope` 与 `sessionId`/`userId` 等键找回。 +- **与 harness 工作区的关系**:宿主机上仍有 `WorkspaceManager` 根目录;沙箱内可见的内容由 `WorkspaceSpec` 与**工作区投影**等机制定义(将部分宿主路径在启动时同步/挂载到沙箱内)。 + +## 2. 在 Harness 中的装配 + +启用沙箱模式时,`HarnessAgent.Builder` 会: + +1. 用 **`SandboxFilesystemSpec#toSandboxContext(hostWorkspaceRoot)`** 得到 **`SandboxContext`**(内含 `SandboxClient`、隔离范围、快照 spec、`WorkspaceSpec` 等),并同时把宿主侧需要投影进沙箱的目录(`AGENTS.md`、`skills/`、`subagents/`、`knowledge/`)装入一个 `WorkspaceProjectionEntry`(见 [§6 工作区投影](#6-工作区投影与-skills-同步))。 +2. 使用 **`SandboxBackedFilesystem`** 作为 agent 的 `AbstractFilesystem` 实现(对上层透明)。 +3. 构造 **`SandboxManager(client, stateStore, agentId)`**;未在 **`SandboxFilesystemSpec#sandboxStateStore`** 上显式配置时,默认使用 **`SessionSandboxStateStore(effectiveSession, agentId)`**,将沙箱元数据与当前 `Session` 关联。 +4. 注册 **`SandboxLifecycleHook(sandboxManager, filesystemProxy)`**(优先级 `50`):在每次 `PreCall` 中 **acquire → `start()`**(含 4-分支工作区初始化,见 [§5 快照与 4-分支恢复](#5-快照与-4-分支恢复)),在 **`PostCall` / `Error`** 中 **`stop()`(持久快照)→ 持久化 state → release** 并清空代理上的活动会话。 + +只有后端实现 **`AbstractSandboxFilesystem`** 时,`HarnessAgent` 才会注册 **`ShellExecuteTool`**;沙箱模式下文件与 shell 命令都走沙箱内部,宿主机不受影响。 + +## 3. 隔离维度(`IsolationScope`) + +`IsolationScope` 控制**沙箱状态的持久化键**(sandbox 模式)以及**共享存储的命名空间前缀**(store 模式,见 [Filesystem 模式一](./filesystem.md))。两个模式共用同一个枚举,语义一致。 + +| 范围 | 持久化键来源 | 缺失时行为 | 典型场景 | +|------|------------|----------|---------| +| `SESSION`(默认) | `sessionKey.toIdentifier()` | 跳过状态查找,创建新沙箱 | 每个会话有独立的沙箱/记忆;对话隔离 | +| `USER` | `RuntimeContext.userId` | 警告并降级到新建 | 同一用户跨会话共享工作区或记忆(含分布式) | +| `AGENT` | agent 名称(构建时固定) | — | 单个 agent 的所有用户和会话共享同一工作区 | +| `GLOBAL` | 固定值 `__global__` | — | 一个 store 内所有 agent/用户/会话全局共享 | + +### 3.1 SESSION — 对话隔离(默认) + +每条对话独立沙箱,互不影响。适合多用户 SaaS,每个会话的临时工作文件、已安装的依赖互相隔离。 + +```java +HarnessAgent agent = HarnessAgent.builder() + .name("code-agent") + .model(model) + .filesystem(new DockerFilesystemSpec() + .image("ubuntu:24.04") + .snapshotSpec(new OssSnapshotSpec(...))) + // isolationScope 默认即 SESSION,此行可省略 + .filesystem(dockerSpec.isolationScope(IsolationScope.SESSION)) + .build(); + +// 每次 call 传入不同 sessionId → 独立的沙箱 +agent.call(msgs, RuntimeContext.builder().sessionId("user1-session1").build()).block(); +agent.call(msgs, RuntimeContext.builder().sessionId("user1-session2").build()).block(); +``` + +### 3.2 USER — 用户级共享(分布式记忆的推荐方式) + +**最常见的分布式场景**:多 Pod/多进程对同一用户的多个会话并行服务,但用户的长期记忆(`MEMORY.md`、`memory/`)要在所有副本间保持一致。 + +**Sandbox 模式 + USER**:不同会话(不同 Pod)在对话结束后都会向同一个 state slot(键 = `userId`)写入最新的快照引用。下次任意副本处理同一用户时,都能从该快照恢复出同一个工作区。注意这是**顺序复用**而非并发共享:并发请求各自拿到独立的容器运行,但在 `stop()` 时都会更新同一 state slot,最后写入的为准。 + +**Remote 模式 + USER**(无沙箱时的等价方案):`RemoteFilesystemSpec` 用 `userId` 作为 KV 命名空间前缀,所有路由到 `MEMORY.md`、`memory/` 等的读写都落在同一 store key 下,从而实现分布式副本之间的记忆共享,而无需快照。 + +```java +// 沙箱 + USER 隔离:同一用户跨 Pod 共享快照 +HarnessAgent agent = HarnessAgent.builder() + .name("assistant") + .model(model) + .filesystem(new DockerFilesystemSpec() + .image("ubuntu:24.04") + .snapshotSpec(new OssSnapshotSpec(...)) + .isolationScope(IsolationScope.USER)) + .sandboxDistributed(SandboxDistributedOptions.oss(redisSession, ossSnapshotSpec)) + .build(); + +RuntimeContext ctx = RuntimeContext.builder() + .userId("alice") // 相同 userId → 相同 state slot → 可恢复同一工作区 + .sessionId("session-xyz") + .build(); +agent.call(msgs, ctx).block(); +``` + +```java +// Remote 模式 + USER 隔离:轻量级分布式记忆共享(无沙箱) +HarnessAgent agent = HarnessAgent.builder() + .name("assistant") + .model(model) + .filesystem(new RemoteFilesystemSpec(redisStore) + .isolationScope(IsolationScope.USER)) + .build(); +// 同一 userId 的所有副本共享 MEMORY.md / memory/ 目录下的记忆 +``` + +### 3.3 AGENT — Agent 级共享 + +同一个 agent(按名称)的所有用户和会话共享工作区快照或存储命名空间。适合「公共知识库型」agent:全局单一工作区,写入由调用顺序决定,适合工具型、只读型或管理员场景。 + +### 3.4 GLOBAL — 全局共享 + +一个 store/workspace 实例内最大范围的共享,谨慎使用。 + +## 4. 自定义沙箱实例与生命周期管理 + +默认情况下,`SandboxManager` 全权负责沙箱的 create / start / stop / shutdown(**self-managed**)。当你需要**复用已有容器**、**在多个 agent 之间共享一个沙箱**,或**自己管理容器生命周期**时,可通过两种方式将沙箱控制权交还给调用方。 + +### 4.1 传入已有 `Sandbox` 实例(user-managed,最高优先级) + +在每次 `call` 时,通过 `RuntimeContext` 中的 `SandboxContext` 带入一个**已经启动的** `Sandbox` 对象: + +```java +// 提前创建并启动沙箱(容器生命周期由调用方管理) +Sandbox mySandbox = dockerClient.create(workspaceSpec, snapshotSpec, options); +mySandbox.start(); + +// 每次 call 时注入该实例 +SandboxContext callCtx = SandboxContext.builder() + .client(dockerClient) // 同 agent 构建时的 client + .externalSandbox(mySandbox) // ← 明确告知 Manager:这是 user-managed + .build(); + +RuntimeContext ctx = RuntimeContext.builder() + .sessionId("my-session") + .sandboxContext(callCtx) // 覆盖构建时的 defaultSandboxContext + .build(); + +agent.call(msgs, ctx).block(); +// SandboxLifecycleHook 会调用 mySandbox.stop()(持久快照) +// 但 不 会 调用 mySandbox.shutdown(),容器依然运行 +``` + +**行为规则**(`SandboxManager.acquire` 的 4 级优先级): + +| 优先级 | 条件 | 行为 | +|--------|------|------| +| 1(最高) | `SandboxContext.externalSandbox != null` | 直接使用,标记 user-managed;`PostCall` 仅调 `stop()`,不 `shutdown()` | +| 2 | `SandboxContext.externalSandboxState != null` | 从指定 state 恢复,self-managed | +| 3 | `SandboxStateStore` 中有持久化的 state | 按 `IsolationScope` 键恢复,self-managed | +| 4(默认) | 以上均无 | 创建新沙箱,self-managed | + +### 4.2 传入序列化状态(精确恢复特定快照) + +若你已持有某次 `call` 后保存的 `SandboxState` 序列化串,可绕过 `SandboxStateStore` 的自动查找,直接指定要恢复的状态: + +```java +// 从外部获取之前序列化的 state(例如从数据库或请求参数中读取) +String savedStateJson = db.load("checkpoint-2026-04-28"); +SandboxState savedState = dockerClient.deserializeState(savedStateJson); + +SandboxContext callCtx = SandboxContext.builder() + .client(dockerClient) + .externalSandboxState(savedState) // ← 指定 state,SDK 负责 resume + 管理生命周期 + .build(); + +RuntimeContext ctx = RuntimeContext.builder() + .sandboxContext(callCtx) + .build(); + +agent.call(msgs, ctx).block(); +``` + +### 4.3 多 Agent 共享同一沙箱 + +```java +// 主 agent 完成一个 call 后,把沙箱传给下一个 agent 继续使用 +Sandbox sharedSandbox = ...; // 已 start() + +agent1.call(msgs1, RuntimeContext.builder() + .sandboxContext(SandboxContext.builder().externalSandbox(sharedSandbox).client(client).build()) + .build()).block(); + +agent2.call(msgs2, RuntimeContext.builder() + .sandboxContext(SandboxContext.builder().externalSandbox(sharedSandbox).client(client).build()) + .build()).block(); + +// 所有 agent 用完后手动 shutdown +sharedSandbox.shutdown(); +``` + +## 5. 快照与 4-分支恢复 + +`Sandbox.start()` 按 **4 个分支**决定如何初始化工作区,保证在各种「容器是否还在、快照是否可用」的组合下都能正确恢复: + +``` +Branch A: workspaceRootReady=true & 容器内目录仍存在 → 只重新应用 ephemeral 条目(最快,热启动) +Branch B: workspaceRootReady=true & 容器内目录已丢失 → 从快照还原 + 重新应用 ephemeral 条目 +Branch C: workspaceRootReady=false & 快照可用 → 从快照还原 + 重新应用所有条目 +Branch D: workspaceRootReady=false & 无可用快照 → 从 WorkspaceSpec 全量初始化(冷启动) +``` + +`Sandbox.stop()` 执行时若 `SandboxSnapshotSpec` 启用了持久化,则将工作区打成 tar 并存入快照后端(OSS、Redis、本地文件等),同时把 `workspaceRootReady` 置 true。这个 tar 就是下次恢复时供 Branch B/C 使用的**归档**。 + +**`WorkspaceEntry.ephemeral` 标志**:`WorkspaceSpec` 中的每个条目都可以标记为 ephemeral(每次启动都重新写入)或非 ephemeral(进快照一同保存,只在冷启动时写入)。`skills/`、`AGENTS.md` 等宿主侧随时可能更新的文件,以 `WorkspaceProjectionEntry` 的方式处理(下节),而不是 ephemeral flag。 + +**快照 spec 可选类型**: + +| Spec | 存储位置 | +|------|---------| +| `NoopSnapshotSpec`(默认) | 不持久化;容器重建后从 WorkspaceSpec 冷启动 | +| `LocalSnapshotSpec` | 宿主机本地文件(适合单机长期运行) | +| `OssSnapshotSpec` | OSS / S3 兼容存储(适合多副本) | +| `RedisSnapshotSpec` | Redis(适合低延迟、小工作区) | + +## 6. 工作区投影与 Skills 同步 + +**工作区投影**(`WorkspaceProjectionEntry`)是 harness 将宿主机工作区里的特定目录/文件在**每次沙箱启动时**同步进沙箱的机制,是 Skills 等能力在沙箱内运行的基础。 + +### 6.1 投影范围 + +`SandboxFilesystemSpec` 构建 `SandboxContext` 时,默认把以下宿主路径打包进投影: + +``` +AGENTS.md ← agent 身份与指令 +skills/ ← SkillBox 里所有 Skill 的目录(含 SKILL.md 和脚本文件) +subagents/ ← 子 agent 规格文件 +knowledge/ ← 领域知识文件 +``` + +可通过 `SandboxFilesystemSpec#workspaceProjectionRoots(List)` 自定义要投影的根路径,或通过 `workspaceProjectionEnabled(false)` 完全关闭。 + +### 6.2 投影如何工作 + +`WorkspaceProjectionApplier` 在 `Sandbox.start()` 末尾执行: + +1. 遍历所有 `WorkspaceProjectionEntry`,收集宿主侧的文件集合,按路径排序后计算 **SHA-256 内容哈希**。 +2. 将这批文件打包成 tar,通过 `Sandbox.hydrateWorkspace(archive)` 解压到沙箱工作区内对应路径。 +3. 把本次哈希存入 `SandboxState.workspaceProjectionHash`;下次启动时若哈希不变,**跳过投影**(避免重复传输)。 + +这意味着:宿主机上 `skills/` 的内容更新后,下次沙箱 start 时哈希变化,新版文件自动同步进去;沙箱内对 skill 文件的修改不会反向同步回宿主机。 + +### 6.3 Skills 在沙箱内怎么执行 + +Harness 的 `SkillBox` 机制把 `workspace/skills//SKILL.md` 里的说明注入 agent 的 system prompt;model 理解「需要这个 skill」后通过 `ShellExecuteTool` 执行 skill 目录下的脚本或命令。在沙箱模式下,这一切都在沙箱内进行: + +``` +宿主机 workspace/skills/pytest/ +│── SKILL.md # 描述:如何运行 pytest +└── run_tests.sh # 实际脚本 + + ▼ 投影(每次启动时) + +沙箱内 /workspace/skills/pytest/ +│── SKILL.md +└── run_tests.sh + +agent 思考后调用 shell_execute: + "bash /workspace/skills/pytest/run_tests.sh tests/" + ↓ + ExecResult(exitCode=0, stdout="5 passed") +``` + +**优点**:脚本运行在隔离容器内,pip install、apt-get、rm -rf 等操作只影响沙箱工作区,宿主机无感。沙箱被 snapshot 后,已安装的依赖也会随工作区一起被归档,下次恢复时直接可用(Branch A/B/C),无需重新安装。 + +### 6.4 Shell 命令与脚本的状态持久化 + +`ShellExecuteTool` 调用 `AbstractSandboxFilesystem.execute(cmd, timeout)` → `Sandbox.exec(cmd, timeout)`,在沙箱内执行命令。命令对文件系统的所有更改(新建文件、安装包、写日志等)都保留在沙箱的 overlay/容器内。`stop()` 时这些状态随 tar 快照持久化,下次 `start()` 恢复。 + +因此,跨 `call` 的**状态是完整保留的**: + +``` +call 1: shell_execute("pip install pandas") → pandas 装进沙箱 +call 2: shell_execute("python analyze.py") → 直接可用,无需重装 +call 3: shell_execute("cat results.csv") → 读 call 2 产生的文件 +``` + +## 7. 状态:`SandboxStateStore` 与 `Session` + +- **`SandboxStateStore`**:抽象「与某次隔离键绑定的沙箱元数据(sessionId + 快照引用)」的持久化。便于替换为自定义实现;在 **`SandboxFilesystemSpec#sandboxStateStore`** 上配置(未设置则走默认)。 +- **默认 `SessionSandboxStateStore`**:依赖构建时选定的 `Session`(与 `SessionPersistenceHook` 等共用的**会话抽象**;若你使用 Redis 等分布式 `Session`,沙箱元数据可随之跨进程可见)。 +- **`WorkspaceSession`** 仍负责**工作区布局下的 per-session 配置**;**不要**将 `WorkspaceSession` 的 JSON 与「沙箱 state JSON」混为同一套职责——沙箱的 resume 数据以 **`SandboxStateStore`** 为准。 + +## 8. 分布式与 `sandboxDistributed` + +当多副本或无状态 worker 要共享**同一条逻辑会话**的沙箱恢复能力时,需要: + +- **分布式 `Session`**(如 `RedisSession`),而不仅是默认的 `WorkspaceSession` 文件后端;以及 +- 非 no-op 的 **`SandboxSnapshotSpec`**(将工作区打成可再拉取的归档),在「必须分布式」的校验下才会通过。 + +`HarnessAgent.Builder#sandboxDistributed(SandboxDistributedOptions)` 可统一下发: + +- 覆盖 **`snapshotSpec`**(若提供);**`IsolationScope` 只在 `SandboxFilesystemSpec` 上配置**,不在此重复; +- 在选项中**显式指定**用于沙箱的 `Session`(若与主 `session` 不同); +- 使用 `SandboxDistributedOptions#oss` / `#redis` 等辅助构造常见组合(见类 JavaDoc)。 + +若 `requireDistributed` 为 true 而当前 `effectiveSession` 仍是 `WorkspaceSession` 或快照为 no-op,构建会 **fail-fast**。 + +## 9. 与三种 Filesystem 模式怎么选 + +沙箱是三种**声明式**配置之一。完整对比见 [Filesystem](./filesystem.md#三种声明式模式);此处只给决策要点: + +| 你更需要 | 推荐模式 | +|----------|----------| +| 多实例共享 `MEMORY.md`、会话日志等到 KV,**不要**在宿主跑 shell | `RemoteFilesystemSpec`(见 [Filesystem — 模式一](./filesystem.md)) | +| 单进程/本机、信任 shell、**不要**另起沙箱 | `LocalFilesystemSpec` 或默认本机 + shell(见 [Filesystem — 模式三](./filesystem.md)) | +| **隔离执行**、命令与文件落沙箱、**长会话恢复**、可选**快照 + 集群** | **`SandboxFilesystemSpec`(本文)+ 可选 `sandboxDistributed`** | + +## 10. 子 Agent + +已启用 `SubagentsHook` 时,若主 agent 在沙箱模式下构建,**子 agent 的 filesystem 会复用**同一 `SandboxBackedFilesystem` 的会话绑定策略(以当前实现为准,便于在同一次编排树内共享环境)。子 agent 本身仍是独立 `ReActAgent`;隔离边界与主 agent 的沙箱 spec 一致。 + +## 11. 延伸阅读 + +- [Filesystem](./filesystem.md) — 类层次、三种模式、`abstractFilesystem` 逃生口 +- [工具](./tool.md) — `FilesystemTool`、`ShellExecuteTool` 入参 +- [会话](./session.md) — `Session` 与 `WorkspaceSession` +- [架构](./architecture.md) — Hook 协作与时序 diff --git a/docs/zh/harness/session.md b/docs/zh/harness/session.md new file mode 100644 index 000000000..6f8900e4f --- /dev/null +++ b/docs/zh/harness/session.md @@ -0,0 +1,111 @@ +# 会话(Session) + +## 作用 + +让 agent 能在跨请求、跨进程、多用户场景下恢复状态。一次 `call()` 结束后自动落盘两路产出: + +- **StateModule 快照**(`Memory`、`ToolExecutionContext` 等可序列化状态)——默认走 `WorkspaceSession` +- **对话 JSONL**(LLM 上下文 + 完整历史)——走 `SessionTree`,由 `MemoryFlushManager.offloadMessages` 触发 + +两者是**两个并行路径 + +## 触发 + +| 时机 | 动作 | +|------|------| +| `agent.call(msg, ctx)` | `bindRuntimeContext` 以 `ctx.session/sessionKey` 交给 `delegate.loadIfExists` 恢复 StateModule | +| `PostCallEvent` / `ErrorEvent` | `SessionPersistenceHook`(priority 900)调 `agent.saveTo(session, sessionKey)`,成功 / 失败都保一份 | +| 压缩 / `PostCallEvent` flush | `MemoryFlushManager.offloadMessages` 追写到 `.jsonl` + `.log.jsonl` | +| 会话结束 | `WorkspaceManager.updateSessionIndex` 更新 `sessions.json` 供 `session_list` 查 | + +## 关键逻辑 + +### 双轨存储布局 + +```mermaid +flowchart LR + Call[agent.call] --> Hook[SessionPersistenceHook] + Hook -->|saveTo / loadIfExists| WS[(WorkspaceSession
    StateModule 快照)] + Call --> Compact[CompactionHook / MemoryFlushHook] + Compact -->|offloadMessages| ST[(SessionTree
    JSONL 双文件)] + WSWrite[WorkspaceManager
    updateSessionIndex] --> Idx[(sessions.json
    会话索引)] + Compact --> WSWrite +``` + +``` +workspace/agents// +├── context/ ← WorkspaceSession 负责 +│ └── / +│ ├── memory.json ← ReActAgent.memory 快照 +│ └── *.json ← 其他 StateModule 序列化产物 +└── sessions/ ← SessionTree + WorkspaceManager 负责 + ├── sessions.json ← 会话索引 (sessionId / summary / updatedAt) + ├── .jsonl ← LLM 可见的压缩上下文 + └── .log.jsonl ← 完整对话日志(append-only,不被压缩) +``` + +- **`context/`**:`WorkspaceSession` 继承 `JsonSession`,base 在 `agents//context/`;sessionId 子目录里按 `SessionKey → {key}.json` 存每个 `StateModule`。 +- **`sessions/`**:`SessionTree` 在一个 JSONL 里按 `id/parentId` 组成一棵树;另一份同名的 `.log.jsonl` **从不被压缩**,供审计和 `session_search` 使用。 + +### `RuntimeContext` 怎么让二者对齐 + +```java +RuntimeContext ctx = RuntimeContext.builder() + .sessionId("sess-001") + .userId("alice") + .build(); + +agent.call(msg, ctx).block(); +``` + +`HarnessAgent.bindRuntimeContext` 会做几件事: + +1. **补默认**:`session` 为空时使用构建时的 `defaultSession`(默认是 `WorkspaceSession(workspace, agentId)`);`sessionKey` 为空时依次试 `SimpleSessionKey.of(sessionId)` → `SimpleSessionKey.of(agentName)`。 +2. **传递到 hooks**:`workspaceContextHook`、`memoryFlushHook`、`sessionPersistenceHook`、`compactionHook` 都会同步该 ctx,他们在 offload / saveTo 时都能读到 `sessionId`。 +3. **联动 `userIdRef`**:`AtomicReference` 被顶下 `userId`,默认 `NamespaceFactory → List.of(userId)` 会以该 userId 作为路径前缀,从而多租户透明隔离。 +4. **预加载状态**:若 `session && sessionKey` 都有,调用 `delegate.loadIfExists` 覆盖当前 Memory。不存在则什么都不动。 + +### 默认与自定义 Session + +```java +// 1. 默认:什么都不传 → WorkspaceSession(workspace, agentId) +HarnessAgent.builder() + .name("MyAgent").model(model).workspace(workspace).build(); + +// 2. 使用任意指定路径的 JsonSession +HarnessAgent.builder() + ... + .session(new JsonSession(Path.of("/custom/sessions"))) + .build(); + +// 3. 调用时临时覆盖 +agent.call(msg, RuntimeContext.builder() + .sessionId("sess-001") + .session(customSession) + .sessionKey(SimpleSessionKey.of("sess-001")) + .build()) + .block(); +``` + +### 多用户隔离的两个层面 + +- **会话层**:`sessionId` 决定 `context//` 与 `sessions/.jsonl` 独立。 +- **文件层**:`userId` + `NamespaceFactory` 决定文件操作路径前缀(默认 `LocalFilesystemWithShell` 会读 `userIdRef`)。 + +```java +// 同一 agent 实例服务 alice / bob +agent.call(msg, RuntimeContext.builder().sessionId("alice-1").userId("alice").build()).block(); +agent.call(msg, RuntimeContext.builder().sessionId("bob-1").userId("bob").build()).block(); +// 两个会话状态、文件路径都互不干扰 +``` + +### 会话索引 + +`MemoryFlushManager.offloadMessages` 调完后,`WorkspaceManager.updateSessionIndex(agentId, sessionId, summary)` 会合并写 `sessions/sessions.json`,agent 在另一轮可以走 `session_list` 工具查看“该 agent 历史上都跟谁聊过”。 + +## 相关文档 + +- [工具](./tool.md) — `session_search` / `session_list` / `session_history` 的入参 +- [记忆](./memory.md) — `offloadMessages` 什么时候被调,怎么反过来被 `memory_search` 利用 +- [文件系统](./filesystem.md) — `userIdRef` + `NamespaceFactory` 的多租户路径隔离 +- [架构](./architecture.md) — `SessionPersistenceHook` 在 PostCallEvent / ErrorEvent 中的位置 diff --git a/docs/zh/harness/subagent.md b/docs/zh/harness/subagent.md new file mode 100644 index 000000000..0ca2240b0 --- /dev/null +++ b/docs/zh/harness/subagent.md @@ -0,0 +1,115 @@ +# 子 Agent(Subagent) + +## 作用 + +让父 agent 能把“独立、重上下文、可并行”的子任务交出去,不打扰主线。子 agent 是**临时**的 `HarnessAgent` 实例:独立 sysPrompt、独立 Memory、不共享父对话历史,仅返回一条结果作为 `tool_result`;同时支持同步 / 异步两种调用。 + +## 触发 + +| 时机 | 动作 | +|------|------| +| `HarnessAgent.build()` | 非 leaf 且有 model 时注册 `SubagentsHook`(priority 80)与 `AgentSpawnTool` / `TaskTool` | +| `PreReasoningEvent` | `SubagentsHook` 拼的“Subagents”指南段 + 所有可用 agent_id 注入第一条 SYSTEM 消息 | +| reasoning 选中子 agent 工具 | `agent_spawn` / `agent_send` / `agent_list` 走同步路径;`timeout_seconds=0` 走异步,返 `task_id` | +| 后续轮次 | `task_output` / `task_cancel` / `task_list` 调 `TaskRepository` 拿结果、取消、查看 | + +> 在 session mode(`AgentBootstrap` 下 `externalSubagentTool != null`)中,上面三个 `agent_*` 工具会被重命名为 `sessions_spawn` / `sessions_send` / `sessions_list`。 + +## 关键逻辑 + +### Spec 来源与汇集 + +```mermaid +flowchart LR + Built[内置 general-purpose
    镜像父配置 + asLeafSubagent] --> Entries[buildSubagentEntries] + Spec[编程 SubagentSpec
    builder.subagent ] --> Entries + MD[workspace/subagents/*.md
    AgentSpecLoader] --> Entries + Custom[builder.subagentFactory
    name to Function] --> Entries + Entries --> Hook[SubagentsHook] + Hook --> ToolMain{工具集} + ToolMain -->|tools()| Spawn[AgentSpawnTool] + ToolMain -->|tools()| TaskT[TaskTool] +``` + +- **内置 `general-purpose`**:镜像主 agent 的 model / workspace / hooks / skills 等配置,调用 `asLeafSubagent()` 禁用递归,适合任意可委派的子任务。 +- **编程 `SubagentSpec`**:`builder.subagent(spec)` 一个个加。 +- **`workspace/subagents/*.md`**:`AgentSpecLoader.loadFromDirectory` 递归扫,解析 YAML front matter + Markdown body。 +- **自定义工厂**:`builder.subagentFactory(name, Function)`,完全控制构建逻辑。 + +### Spec 的两种描述形式 + +**Markdown front matter**(`workspace/subagents/research.md`)——推荐: + +```markdown +--- +name: research-analyst +description: 调研主题、查找文档、汇总外部信息。 +model: qwen3-max +maxIters: 15 +tools: read_file, grep_files +--- + +你是一名研究分析师。输出带引用、不确定处要明说。 +``` + +`AgentSpecLoader.parse` 实际仅读 `name` / `description` / `tools`(逗号分)/ `model` / `maxIters`;body 作为 `sysPrompt`。 +`SubagentSpec` 还有 `workspace` 字段,但当前只在**编程式**(手动 `setWorkspace`)生效,Markdown 里写不会被读。 + +**编程**: + +```java +SubagentSpec spec = new SubagentSpec("data-analyst", "SQL / 数据聚合 / 趋势"); +spec.setSysPrompt("你是数据分析专家..."); +spec.setMaxIters(10); + +HarnessAgent.builder() + .name("Orchestrator").model(model).workspace(workspace) + .subagent(spec) + .build(); +``` + +### 防递归 + 防超深 + +- `SubagentSpec` 生成的子 agent 都调了 `Builder.asLeafSubagent()`:`leafSubagent=true` 时 `build()` **不注册** `SubagentsHook`,因此子 agent 看不到这些工具,无法再 spawn。 +- 在 `AgentSpawnTool` 里还额外限了一道防线:`MAX_SPAWN_DEPTH = 3`,作为动态保险。 + +### 调用语义 + +| 工具 | 作用 | 关键参数 | +|------|------|---------| +| `agent_spawn` | 生成一个子 agent 跑一件任务 | `agent_id`(必填)、`task`(可选,留空则只建 session 不跑)、`label`(可选别名)、`timeout_seconds` 默认 30s,`0` 走后台,上限 600s | +| `agent_send` | 给已存在的子 agent 补一条话 | `agent_key` (spawn 返回的句柄,不是 `agent_id`/`task_id`)或 `label`;`message`;`timeout_seconds` | +| `agent_list` | 列当前活跃子 agent | 无 | +| `task_output` | 取后台任务结果 | `task_id`、`block`(默认 true)、`timeout` 默认 30s,上限 600s | +| `task_cancel` | 取消任务 | `task_id` | +| `task_list` | 列任务,可按状态过滤 | `status_filter`:running / completed / failed / cancelled | + +### TaskRepository 与 BackgroundTask + +- 默认 `DefaultTaskRepository` 是进程内 `ConcurrentHashMap` + cached daemon thread pool。要跨进程(如 Redis / DB)只需实现 `TaskRepository` 接口 并 `builder.taskRepository(...)`。 +- `BackgroundTask` 包装 `CompletableFuture`,记录 `taskId / agentId / createdAt / lastCheckedAt`。 +- `TaskStatus`:`PENDING` / `RUNNING` / `COMPLETED` / `FAILED` / `CANCELLED`,`isTerminal()` 返回后三者。 + +## 配置示例 + +```java +HarnessAgent orchestrator = HarnessAgent.builder() + .name("orchestrator").model(model).workspace(workspace) + .subagent(researchSpec) // (1) 编程 + .subagentFactory("my-specialist", id -> // (2) 自定义工厂 + HarnessAgent.builder().name(id).model(specialModel) + .workspace(Path.of("./specialist-workspace")) + .toolkit(customToolkit).build()) + .taskRepository(new RedisTaskRepository(...)) // (可选) + .build(); +// (3) workspace/subagents/*.md 会被自动扫描 +// (4) 内置 general-purpose 总是在位 +``` + +编排 prompt 中子 agent 如何被选中完全依赖 `description`,尽量明确“何时用 / 何时不用 / 输出形式”。同时 `maxIters` 宜比父 agent 小,避免子任务贪吃 token。 + +## 相关文档 + +- [工具](./tool.md) — `agent_spawn` / `agent_send` / `agent_list` / `task_*` 的完整参数表 +- [工作区](./workspace.md) — `workspace/subagents/` 与自动发现 +- [架构](./architecture.md) — SubagentsHook 在生命周期中的位置与同步 / 后台两条委派路径的时序图 diff --git a/docs/zh/harness/tool.md b/docs/zh/harness/tool.md new file mode 100644 index 000000000..42218d2cb --- /dev/null +++ b/docs/zh/harness/tool.md @@ -0,0 +1,104 @@ +# 工具(Tool) + +## 作用 + +harness 层默认为 agent 提供一套“足够走完一个闭环”的内置工具:读写文件、检索记忆与会话、委派子 agent、可选走 shell。不需手动注册,`HarnessAgent.build()` 与 `SubagentsHook` 会一起装好。 + +## 注册路径 + +```mermaid +flowchart LR + Build[HarnessAgent.build] --> R1[FilesystemTool] + Build --> R2[MemorySearchTool] + Build --> R3[MemoryGetTool] + Build --> R4[SessionSearchTool] + Build -. backend 是 sandbox .-> R5[ShellExecuteTool] + Hook[SubagentsHook.tools
    非 leaf 且有 model] --> H1[AgentSpawnTool] + Hook --> H2[TaskTool] +``` + +- **直接 register**:`FilesystemTool` / `MemorySearchTool` / `MemoryGetTool` / `SessionSearchTool` 必装;`ShellExecuteTool` 仅在 `backend instanceof AbstractSandboxFilesystem` 时装。 +- **间接 register**:`AgentSpawnTool` 与 `TaskTool` 是 `SubagentsHook.tools()` 返回的,仅在非 leaf 且配了 `model` 时出现;在 session mode 下 `agent_*` 会被 `sessions_*` 替换。 + +## 文件系统·`FilesystemTool` + +包装 `AbstractFilesystem`;路径是后端本地路径。 + +| 工具 | 作用 | 参数 | +|------|------|------| +| `read_file` | 读文件内容 | `path`, `offset`(0-indexed), `limit`(0 = 读全) | +| `write_file` | 创建新文件 | `path`, `content`(已存在会报错) | +| `edit_file` | 精确字符串替换 | `path`, `old_string`(默认唯一), `new_string`, `replace_all`(默认 false) | +| `grep_files` | 指定路径中搜字符串(非正则)| `pattern`, `path`, `glob`(如 `*.java`) | +| `glob_files` | 按 glob 查文件 | `pattern`(如 `**/*.md`), `path` | +| `list_files` | 列目录 | `path` | + +## 记忆·`MemorySearchTool` / `MemoryGetTool` + +| 工具 | 作用 | 参数 | +|------|------|------| +| `memory_search` | FTS5 全文检索,最多返 30 条;MemoryIndex 不可用时 fallback 到关键字扫 | `query` | +| `memory_get` | 读记忆文件中指定行范围,输出带行号 | `path`(工作区相对), `startLine`, `endLine`(1-based)| + +> 参数名是驼峰(`startLine` / `endLine`),与 filesystem 的 snake_case 不一致。 + +## 会话·`SessionSearchTool` + +| 工具 | 作用 | 参数 | +|------|------|------| +| `session_search` | 在会话 JSONL 中扫关键词 | `query`, `agentId`(可选), `maxResults`(默认 10) | +| `session_list` | 列某个 agent 的会话,优先读 `sessions.json` | `agentId` | +| `session_history` | 返某个会话最近 N 条消息 | `agentId`, `sessionId`, `lastN`(默认 20)| + +> 参数名都是驼峰,且 `session_search` 返回结果是扫全部 `agents//sessions/*.jsonl` 后的“首次命中 10 条”,**不是**按相关性排序。 + +## 子 Agent·`AgentSpawnTool` + +| 工具 | 作用 | 参数 | +|------|------|------| +| `agent_spawn` | 创建临时子 agent、可选走任务 | `agent_id`(必填), `task`(可选,留空仅建 session), `label`(可选别名), `timeout_seconds`(默认 30,`0`=后台,上限 600) | +| `agent_send` | 向已存在子 agent 补一条 | `agent_key` 或 `label`(二选一), `message`, `timeout_seconds` | +| `agent_list` | 列当前子 agent | 无 | + +``` +agent_spawn agent_id="research-analyst" + task="调研主题 X" + timeout_seconds=60 + +# 异步 +agent_spawn agent_id="research-analyst" task="全库安全审计" timeout_seconds=0 +# → agent_key + task_id +``` + +Session mode 下,这三个名会变为 `sessions_spawn` / `sessions_send` / `sessions_list`。 + +## 后台任务·`TaskTool` + +| 工具 | 作用 | 参数 | +|------|------|------| +| `task_output` | 拿后台任务结果 | `task_id`, `block`(默认 true), `timeout` 默认 30000ms,上限 600000ms | +| `task_cancel` | 取消任务;终态不生效 | `task_id` | +| `task_list` | 列任务 | `status_filter`:running / completed / failed / cancelled / all | + +## Shell·`ShellExecuteTool`(条件性装) + +仅在后端是 `AbstractSandboxFilesystem`(包含 `LocalFilesystemWithShell`)时才被注册。如果你用的是纯 `LocalFilesystem` 或 `RemoteFilesystem`,子工具不出现。 + +| 工具 | 作用 | 参数 | +|------|------|------| +| `execute` | 走后端 `execute()`,返 stdout + exit code | `command`, `working_directory`(可选,实际拼接为 `cd

    && `), `timeout`(秒,默认 30)| + +> **注意**:@Tool 未显式设 `name`,默认取方法名,所以 LLM 看到的工具名是 `execute`。如果后续统一为 `shell_execute` 是个小重构,参见 [roadmap](./roadmap.md)。 + +``` +execute command="find . -name '*.java' | wc -l" +execute command="mvn test" timeout=300 +execute command="git status" working_directory="app" # 拼为 cd app && git status +``` + +## 相关文档 + +- [文件系统](./filesystem.md) — 后端实现与沙箱接口 +- [记忆](./memory.md) — `memory_search` / `memory_get` 背后的 FTS5 与双层记忆 +- [会话](./session.md) — `session_*` 系列背后的 `WorkspaceSession` / `SessionTree` 双轨 +- [子 Agent](./subagent.md) — `agent_spawn` / `agent_send` / `task_*` 的调度与生命周期 diff --git a/docs/zh/harness/workspace.md b/docs/zh/harness/workspace.md new file mode 100644 index 000000000..35c46b859 --- /dev/null +++ b/docs/zh/harness/workspace.md @@ -0,0 +1,119 @@ +# 工作区(Workspace) + +## 作用 + +工作区是 `HarnessAgent` 的"地基":人格、长期记忆、领域知识、子 agent 规格、会话历史、技能定义统一以**目录结构 + Markdown** 的形式落地,不再散落在代码里。 + +agent 每次推理时,工作区里的几个关键文件会被自动注入到 system prompt;运行过程中的记忆与会话也会按既定路径回写到这里。 + +## 触发 + +| 时机 | 动作 | +|------|------| +| `HarnessAgent.build()` | `WorkspaceManager.validate()` 检查目录与 `AGENTS.md` 是否存在,缺失只 warn | +| 每次 `call()` 推理前 | `WorkspaceContextHook` 读 `AGENTS.md` / `MEMORY.md` / `knowledge/` / 额外文件并注入 system prompt | +| 压缩 / 调用结束 | `MemoryFlushHook`、`SessionPersistenceHook` 等通过 `WorkspaceManager` 写回 `memory/`、`agents/.../sessions/` | + +## 目录结构 + +``` +workspace/ ← 默认 .agentscope/workspace +├── AGENTS.md ← 人格 / 行为约定(每次注入全文) +├── MEMORY.md ← 整理过的长期记忆(每次注入,受 token 预算) +├── knowledge/ +│ ├── KNOWLEDGE.md ← 领域知识入口 +│ └── * ← 其他参考文件,按需 read_file 打开 +├── memory/ +│ ├── YYYY-MM-DD.md ← 每日记忆流水账(追加,由 MemoryFlushManager 写入) +│ └── .consolidation_state ← MemoryConsolidator 内部状态 +├── skills//SKILL.md ← 自定义技能 +├── subagent.yml ← 子 agent 规格(可选) +└── agents// + └── sessions/ + ├── sessions.json ← 会话索引(id / summary / updatedAt) + ├── .jsonl ← LLM 可见的压缩上下文 + └── .log.jsonl← 完整对话日志(追加) +``` + +> 子 agent 还支持 `workspace/subagents/*.md` 自动发现,详见 [子 Agent](./subagent.md)。 + +## 关键逻辑 + +### 两层读取 / 写回 + +`WorkspaceManager` 是无状态访问器,所有读写都遵循同一规约: + +```mermaid +flowchart LR + Caller[Hook / Tool] -->|read| WM[WorkspaceManager] + WM -->|read 优先| FS[AbstractFilesystem
    多租户 namespace 透明] + FS -- 命中非空 --> WM + FS -- 空 --> LD[本地磁盘
    workspace/...] + LD --> WM + + Caller -->|write| WM + WM -->|appendUtf8 / uploadFiles| FS2[AbstractFilesystem] + WM -. filesystem 缺省 .-> LD2[本地磁盘兜底] +``` + +要点: + +- **读路径**:`AbstractFilesystem` 优先 → 本地磁盘兜底,让多租户场景对调用方透明 +- **写路径**:默认全部走 `AbstractFilesystem`;未配置时 fallback 本地磁盘 +- **List 操作**(`listKnowledgeFiles` / `listMemoryFilePaths` / `listSessionLogFiles`)取两层并集去重,避免漏文件 + +### system prompt 注入内容 + +`WorkspaceContextHook`(priority 900)在 `PreReasoningEvent` 拼装一段固定结构的文本,合并到第一条 SYSTEM 消息: + +| 段落 | 来源 | Token 预算 | +|------|------|------------| +| `## Session Context` | 模板生成(日期、OS、workspace 路径、`runtimeContext.sessionId`) | 不限 | +| `## Workspace` 等 guidance | 内置模板 | 不限 | +| `` XML 块 | — | — | +| ↳ `` | `AGENTS.md` | 全文 | +| ↳ `` | `MEMORY.md` | 受 `maxContextTokens` 限制 | +| ↳ `` | `knowledge/KNOWLEDGE.md` + `listKnowledgeFiles()` 列表 | 全文 + 路径目录 | +| ↳ `<{rel_path}>` | 每个 `additionalContextFile` | 全文 | + +`maxContextTokens` 默认 `8000`(按 `chars/4` 估算)。当 `MEMORY.md` 估算超出"剩余预算"时,按字符截断并附 `... (memory truncated — use memory_search for older entries) ...` 尾注,提示 agent 改走 `memory_search`。 + +### 关键 API + +```java +WorkspaceManager wm = new WorkspaceManager(workspace, abstractFilesystem); + +wm.readAgentsMd(); // 两层读 +wm.readMemoryMd(); +wm.readKnowledgeMd(); // 注意:读 knowledge/KNOWLEDGE.md +wm.readManagedWorkspaceFileUtf8(rel); // 任意工作区相对路径,做 path traversal 校验 + +wm.listKnowledgeFiles(); // 两层并集 +wm.listMemoryFilePaths(); +wm.listSessionLogFiles(); + +wm.appendUtf8WorkspaceRelative(rel, content); // 走 AbstractFilesystem +wm.updateSessionIndex(agentId, sessionId, summary); // 维护 sessions.json +``` + +## 配置 + +```java +HarnessAgent agent = HarnessAgent.builder() + .name("MyAgent") + .model(model) + .workspace(Paths.get(".agentscope/workspace")) // 不传则用默认 + .additionalContextFile("SOUL.md") // 任意工作区相对路径 + .additionalContextFile("PREFERENCES.md") + .maxContextTokens(8000) // 控制 MEMORY 的注入上限 + .build(); +``` + +`AGENTS.md` 缺失时 agent 仍可工作,只会丢失 persona 段,建议至少写一份最小骨架(参考 [overview.md](./overview.md) 的 quickstart)。 + +## 相关文档 + +- [架构](./architecture.md) — `WorkspaceContextHook` 在 call() 生命周期里的位置 +- [文件系统](./filesystem.md) — 两层读路径中"上层"的实现 +- [记忆](./memory.md) — `MEMORY.md` / `memory/*.md` 怎么生成与维护 +- [会话](./session.md) — `agents//sessions/` 的细节 diff --git a/docs/zh/intro.md b/docs/zh/intro.md index ba17ce5fc..8c4b73236 100644 --- a/docs/zh/intro.md +++ b/docs/zh/intro.md @@ -98,6 +98,7 @@ System.out.println(response.getTextContent()); - **[内存管理](task/memory.md)** - 管理对话历史和长期内存 - **[计划](task/plan.md)** - 复杂多步骤任务的计划管理 - **[智能体配置](task/agent-config.md)** - 高级智能体配置选项 +- **[HarnessAgent](harness/overview.md)** - 具备工作区、持久记忆与子 Agent 编排的生产级运行时 ### 多智能体系统 - **[管道](multi-agent/pipeline.md)** - 使用顺序和并行执行构建多智能体工作流 diff --git a/docs/zh/task/model.md b/docs/zh/task/model.md index ecb8e14c7..1bee30889 100644 --- a/docs/zh/task/model.md +++ b/docs/zh/task/model.md @@ -26,6 +26,86 @@ | Gemini | [Google AI Studio](https://aistudio.google.com/apikey) | `GEMINI_API_KEY` | | DeepSeek | [DeepSeek 开放平台](https://platform.deepseek.com/api_keys) | - | +## ModelRegistry + +[`ModelRegistry`](https://github.com/agentscope-ai/agentscope-java/blob/main/agentscope-core/src/main/java/io/agentscope/core/model/ModelRegistry.java)(`io.agentscope.core.model.ModelRegistry`)用**字符串 id**得到 `Model` 实例,适合不想手写各厂商 `*ChatModel.builder()` 的场景。例如 Harness 场景下可使用 `HarnessAgent.builder().model(String)`;任意需要 `Model` 的地方可先调用 `ModelRegistry.resolve(...)` 再传入 `ReActAgent` 等构建器。 + +### API 一览 + +| 方法 | 说明 | +|------|------| +| `register(String name, Model model)` | 注册**具名**模型;之后对同名 id 调用 `resolve` 直接返回该实例。 | +| `registerFactory(String regex, ModelFactory factory)` | 为匹配正则的 id 注册自定义工厂;**后注册的工厂优先**于更早注册的用户工厂,且优先于内置规则。 | +| `resolve(String modelId)` | 解析并返回 `Model`;无法解析或创建失败时抛出 `IllegalArgumentException`。 | +| `canResolve(String modelId)` | 仅判断是否可解析(不创建实例)。 | +| `reset()` | 清空具名注册、用户工厂与解析缓存;内置规则保留。一般仅在测试或进程内重置时使用。 | + +`ModelFactory` 为函数式接口:`Model create(String modelId)`,参数为完整模型 id 字符串。 + +### 内置 id 格式与环境变量 + +在已配置对应环境变量的前提下,可使用下列 id 形式(适用于 `resolve` 以及 `HarnessAgent.Builder.model(String)` 等): + +| id 示例 | 所需环境变量 | 说明 | +|---------|--------------|------| +| `openai:gpt-4o-mini` | `OPENAI_API_KEY` | OpenAI 兼容 HTTP 模型 | +| `dashscope:qwen-max` | `DASHSCOPE_API_KEY` | 阿里云 DashScope / 百炼 | +| `qwen-max` 等以 `qwen-` 开头的 id | `DASHSCOPE_API_KEY` | 将整个字符串作为 DashScope 的 `modelName` | +| `anthropic:claude-sonnet-4-5-20250929` | `ANTHROPIC_API_KEY`(可选;未设置时可依赖 SDK 从环境读取) | Anthropic Claude | +| `gemini:gemini-2.5-flash` | `GEMINI_API_KEY` | Google Gemini API | +| `ollama:llama3` | `OLLAMA_BASE_URL`(可选,默认 `http://localhost:11434`) | 本地 Ollama | + +同一进程内,对**相同**工厂解析 id 多次时,返回的 `Model` 实例会被缓存复用;**具名**注册不走该缓存。 + +### 示例:具名注册(自定义配置后复用) + +需要先精细配置(温度、超时等)时,用 Builder 构建一次,再注册成名字: + +```java +import io.agentscope.core.model.GenerateOptions; +import io.agentscope.core.model.ModelRegistry; +import io.agentscope.core.model.OpenAIChatModel; +import io.agentscope.harness.agent.HarnessAgent; + +Model tuned = OpenAIChatModel.builder() + .apiKey(System.getenv("OPENAI_API_KEY")) + .modelName("gpt-4o") + .generateOptions(GenerateOptions.builder().temperature(0.2).build()) + .build(); +ModelRegistry.register("my-gpt4o", tuned); + +HarnessAgent agent = HarnessAgent.builder() + .name("demo") + .model("my-gpt4o") + .workspace(workspace) + .build(); +``` + +### 示例:内置前缀(默认连接参数) + +```java +import io.agentscope.harness.agent.HarnessAgent; + +HarnessAgent agent = HarnessAgent.builder() + .name("demo") + .model("dashscope:qwen-max") + .workspace(workspace) + .build(); +``` + +### 示例:自定义工厂 + +```java +import io.agentscope.core.model.Model; +import io.agentscope.core.model.ModelRegistry; + +ModelRegistry.registerFactory( + "my-llm:.+", + id -> myModelFactory(id.substring("my-llm:".length()))); + +Model m = ModelRegistry.resolve("my-llm:prod"); +``` + ## DashScope 阿里云 LLM 平台,提供通义千问系列模型。 diff --git a/pom.xml b/pom.xml index 0f3848486..8d1b6ae6d 100644 --- a/pom.xml +++ b/pom.xml @@ -55,6 +55,7 @@ agentscope-core + agentscope-harness agentscope-extensions agentscope-examples agentscope-dependencies-bom