diff --git a/.gitignore b/.gitignore index 4cc3d6869..5498a7cff 100644 --- a/.gitignore +++ b/.gitignore @@ -39,6 +39,7 @@ build/ ### Docs docs/_build +.venv/ # vibe coding diff --git a/.licenserc.yaml b/.licenserc.yaml index 0523beec0..51a8109c0 100644 --- a/.licenserc.yaml +++ b/.licenserc.yaml @@ -72,6 +72,7 @@ header: - 'CNAME' - 'Jenkinsfile' - '**/vendor/**' + - '**/docs/**' - '**/.prettierrc' comment: on-failure diff --git a/agentscope-examples/harness-examples/harness-example-sandbox/README.md b/agentscope-examples/harness-examples/harness-example-sandbox/README.md index 23ee842e3..bbdf49877 100644 --- a/agentscope-examples/harness-examples/harness-example-sandbox/README.md +++ b/agentscope-examples/harness-examples/harness-example-sandbox/README.md @@ -73,4 +73,4 @@ curl -s -X POST http://localhost:8787/query \ | `support/InMemorySandboxFilesystemSpec.java` | `SandboxFilesystemSpec` + `SandboxClient` | | `support/SharedInMemorySandboxStateStore.java` | 分布式沙箱元数据的单机替身 | -更完整的沙箱概念见 [`docs/zh/harness/sandbox.md`](../../docs/zh/harness/sandbox.md)。 +更完整的沙箱概念见 [`docs/zh/harness/sandbox/index.md`](../../docs/zh/harness/sandbox/index.md)。 diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java index fe29571f3..ddca98664 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/HarnessAgent.java @@ -32,6 +32,7 @@ import io.agentscope.core.model.Model; import io.agentscope.core.model.ModelRegistry; import io.agentscope.core.model.StructuredOutputReminder; +import io.agentscope.core.model.ToolSchema; import io.agentscope.core.plan.PlanNotebook; import io.agentscope.core.rag.Knowledge; import io.agentscope.core.rag.RAGMode; @@ -80,10 +81,12 @@ 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.SubagentDeclaration; import io.agentscope.harness.agent.subagent.SubagentFactory; -import io.agentscope.harness.agent.subagent.SubagentSpec; +import io.agentscope.harness.agent.subagent.WorkspaceMode; import io.agentscope.harness.agent.subagent.task.DefaultTaskRepository; import io.agentscope.harness.agent.subagent.task.TaskRepository; +import io.agentscope.harness.agent.subagent.task.WorkspaceTaskRepository; import io.agentscope.harness.agent.tool.FilesystemTool; import io.agentscope.harness.agent.tool.MemoryGetTool; import io.agentscope.harness.agent.tool.MemorySearchTool; @@ -414,6 +417,14 @@ public WorkspaceManager getWorkspaceManager() { return workspaceManager; } + /** + * Returns the {@link CompactionHook} instance if compaction was configured, or {@code null}. + * Exposed for testing to verify compaction mirroring in child agents. + */ + public CompactionHook getCompactionHook() { + return compactionHook; + } + public RuntimeContext getRuntimeContext() { return runtimeContext; } @@ -571,7 +582,7 @@ public static class Builder { */ private ToolResultEvictionConfig toolResultEvictionConfig = null; - private final List subagentSpecs = new ArrayList<>(); + private final List subagentDeclarations = new ArrayList<>(); private final List customSubagentFactories = new ArrayList<>(); private TaskRepository taskRepository; private Object externalSubagentTool; @@ -1094,14 +1105,17 @@ public Builder sandboxDistributed(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); + /** + * Adds a subagent declaration (programmatic; workspace declarations come from + * {@code subagents/*.md}). + */ + public Builder subagent(SubagentDeclaration declaration) { + this.subagentDeclarations.add(declaration); return this; } - public Builder subagents(List specs) { - this.subagentSpecs.addAll(specs); + public Builder subagents(List declarations) { + this.subagentDeclarations.addAll(declarations); return this; } @@ -1177,17 +1191,19 @@ public List buildSubagentEntries(Path resolvedWorkspace) { } /** - * 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. + * Builds the subagent entries from programmatic declarations, + * {@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); + List allDeclarations = new ArrayList<>(subagentDeclarations); Path subagentsDir = resolvedWorkspace.resolve("subagents"); if (Files.isDirectory(subagentsDir)) { - allSpecs.addAll(AgentSpecLoader.loadFromDirectory(subagentsDir)); + allDeclarations.addAll( + AgentSpecLoader.loadFromDirectory(subagentsDir, resolvedWorkspace)); } List entries = new ArrayList<>(); @@ -1199,16 +1215,12 @@ public List buildSubagentEntries( + " 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 (SubagentDeclaration decl : allDeclarations) { + entries.add( + new SubagentEntry( + decl.getName(), + decl.getDescription(), + buildDeclaredFactory(decl, resolvedWorkspace, sandboxFs))); } for (SubagentFactoryEntry custom : customSubagentFactories) { @@ -1366,7 +1378,8 @@ public HarnessAgent build() { if (!leafSubagent && !disableSubagents && model != null) { SubagentsHook subagentsHook = - buildSubagentsHook(wsManager, resolvedWorkspace, capturedSandboxFs); + buildSubagentsHook( + wsManager, resolvedWorkspace, capturedSandboxFs, userIdRef); if (subagentsHook != null) { allHooks.add(subagentsHook); } @@ -1584,25 +1597,44 @@ private static NamespaceFactory buildDynamicNamespaceFactory( // ----------------------------------------------------------------- private SubagentsHook buildSubagentsHook( - WorkspaceManager wsManager, Path workspace, SandboxBackedFilesystem sandboxFs) { + WorkspaceManager wsManager, + Path workspace, + SandboxBackedFilesystem sandboxFs, + AtomicReference userIdRef) { List entries = buildSubagentEntries(workspace, sandboxFs); - TaskRepository repo = - taskRepository != null ? taskRepository : new DefaultTaskRepository(); + TaskRepository repo; + if (taskRepository != null) { + repo = taskRepository; + } else if (wsManager != null) { + String resolvedName = name != null ? name : "HarnessAgent"; + repo = new WorkspaceTaskRepository(wsManager, resolvedName); + } else { + repo = new DefaultTaskRepository(); + } if (externalSubagentTool != null) { return new SubagentsHook(entries, externalSubagentTool, repo); } - return new SubagentsHook(entries, repo, wsManager); + return new SubagentsHook(entries, repo, wsManager, userIdRef::get); } /** - * 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. + * Builds a factory for the built-in general-purpose subagent. + * + *

The general-purpose subagent always runs in {@link WorkspaceMode#SHARED} mode: it + * uses the same workspace root and filesystem backend as the main agent, and inherits all + * capability settings (hooks, tool disable flags, skills, execution config, additional + * context files, compaction, etc.) so that its effective capability profile is identical to + * the main agent. The only intentional differences are: + *

    + *
  1. {@link Builder#asLeafSubagent()} — prevents recursive subagent spawning. + *
  2. An independent child session-id, assigned at invoke time. + *
  3. The system prompt is the Subagent Context section only (no base prompt); the + * workspace {@code AGENTS.md} is injected automatically by {@link WorkspaceContextHook}. + *
*/ 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; @@ -1620,54 +1652,47 @@ private SubagentFactory buildGeneralPurposeFactory( final boolean capturedDisableMemoryHooks = this.disableMemoryHooks; final boolean capturedDisableSessionPersistence = this.disableSessionPersistence; final boolean capturedDisableWorkspaceContext = this.disableWorkspaceContext; + final CompactionConfig capturedCompactionConfig = this.compactionConfig; + final ToolResultEvictionConfig capturedToolResultEvictionConfig = + this.toolResultEvictionConfig; + final boolean capturedAgentTracingLogEnabled = this.agentTracingLogEnabled; + final List capturedAdditionalContextFiles = + List.copyOf(this.additionalContextFiles); + final int capturedMaxContextTokens = this.maxContextTokens; return () -> { Builder sub = HarnessAgent.builder() .name("general-purpose-subagent") .description("General-purpose subagent for isolated task execution") - .sysPrompt(buildSubagentSysPrompt(GENERAL_PURPOSE_BASE_PROMPT)) + .sysPrompt(buildSubagentSysPrompt(null)) .model(capturedModel) .workspace(workspace) .asLeafSubagent() .maxIters(capturedMaxIters) .environmentMemory(capturedEnvMemory) - .useLegacyXmlWorkspaceContext(capturedUseLegacyXmlWorkspaceContext); + .useLegacyXmlWorkspaceContext(capturedUseLegacyXmlWorkspaceContext) + .enableAgentTracingLog(capturedAgentTracingLogEnabled) + .maxContextTokens(capturedMaxContextTokens); + + capturedAdditionalContextFiles.forEach(sub::additionalContextFile); + + if (capturedDisableFilesystemTools) sub.disableFilesystemTools(); + if (capturedDisableShellTool) sub.disableShellTool(); + if (capturedDisableMemoryTools) sub.disableMemoryTools(); + if (capturedDisableMemoryHooks) sub.disableMemoryHooks(); + if (capturedDisableSessionPersistence) sub.disableSessionPersistence(); + if (capturedDisableWorkspaceContext) sub.disableWorkspaceContext(); + + 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); + if (capturedCompactionConfig != null) sub.compaction(capturedCompactionConfig); + if (capturedToolResultEvictionConfig != null) + sub.toolResultEviction(capturedToolResultEvictionConfig); - if (capturedDisableFilesystemTools) { - sub.disableFilesystemTools(); - } - if (capturedDisableShellTool) { - sub.disableShellTool(); - } - if (capturedDisableMemoryTools) { - sub.disableMemoryTools(); - } - if (capturedDisableMemoryHooks) { - sub.disableMemoryHooks(); - } - if (capturedDisableSessionPersistence) { - sub.disableSessionPersistence(); - } - if (capturedDisableWorkspaceContext) { - sub.disableWorkspaceContext(); - } - - 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(); @@ -1675,70 +1700,194 @@ private SubagentFactory buildGeneralPurposeFactory( } /** - * 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)}. + * Builds a factory for a user-declared subagent from a {@link SubagentDeclaration}. + * + *

Workspace and system-prompt resolution follows the five-row decision table in + * {@link WorkspaceMode}. When the mode is {@link WorkspaceMode#SHARED}, the parent's + * filesystem backend is reused; when {@link WorkspaceMode#ISOLATED}, a fresh + * {@link io.agentscope.harness.agent.filesystem.local.LocalFilesystem} is created on the + * resolved workspace path. The tools allowlist (if non-empty) is applied after the toolkit + * is assembled. */ - private SubagentFactory buildSpecFactory(SubagentSpec spec, Path defaultWorkspace) { + private SubagentFactory buildDeclaredFactory( + SubagentDeclaration decl, Path mainWorkspace, SandboxBackedFilesystem sandboxFs) { final Model capturedModel = this.model; final Function capturedResolver = this.modelResolver; - final AgentSkillRepository capturedSkillRepo = this.skillRepository; + final AbstractFilesystem capturedSharedBackend = + sandboxFs != null ? sandboxFs : this.abstractFilesystem; final boolean capturedUseLegacyXmlWorkspaceContext = this.useLegacyXmlWorkspaceContext; + final boolean capturedDisableFilesystemTools = this.disableFilesystemTools; + final boolean capturedDisableShellTool = this.disableShellTool; + final boolean capturedDisableMemoryTools = this.disableMemoryTools; + final boolean capturedDisableMemoryHooks = this.disableMemoryHooks; + final boolean capturedDisableSessionPersistence = this.disableSessionPersistence; 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()); - } - } - } + // ---- Resolve workspace root ---- + Path runtimeWorkspace = resolveDeclaredWorkspace(decl, mainWorkspace); + + // ---- Resolve system prompt ---- + String sysPromptBase = resolveDeclaredSysPromptBase(decl); + + // ---- Resolve model ---- + Model effectiveModel = + resolveModel( + decl.getModel(), capturedModel, capturedResolver, decl.getName()); + // ---- Build child agent ---- Builder sub = HarnessAgent.builder() - .name(spec.getName()) - .description( - spec.getDescription() != null ? spec.getDescription() : "") + .name(decl.getName()) + .description(decl.getDescription()) .model(effectiveModel) - .workspace(specWorkspace) - .maxIters(spec.getMaxIters()) + .workspace(runtimeWorkspace) + .maxIters(decl.getMaxIters()) .asLeafSubagent() - .useLegacyXmlWorkspaceContext(capturedUseLegacyXmlWorkspaceContext); + .useLegacyXmlWorkspaceContext(capturedUseLegacyXmlWorkspaceContext) + .sysPrompt(buildSubagentSysPrompt(sysPromptBase)); + + // Shared mode reuses the parent's filesystem backend so MEMORY/sessions are + // namespaced identically; isolated mode gets a plain LocalFilesystem on its own + // workspace root. + if (decl.getWorkspaceMode() == WorkspaceMode.SHARED + && capturedSharedBackend != null) { + sub.abstractFilesystem(capturedSharedBackend); + } - if (capturedSkillRepo != null) { - sub.skillRepository(capturedSkillRepo); + // Propagate disable flags so the declared subagent respects the same capability + // restrictions as the main agent. + if (capturedDisableFilesystemTools) sub.disableFilesystemTools(); + if (capturedDisableShellTool) sub.disableShellTool(); + if (capturedDisableMemoryTools) sub.disableMemoryTools(); + if (capturedDisableMemoryHooks) sub.disableMemoryHooks(); + if (capturedDisableSessionPersistence) sub.disableSessionPersistence(); + + // Apply tools allowlist: retain only the requested tool names. + if (!decl.getTools().isEmpty()) { + sub.toolkit(new Toolkit()); } - sub.sysPrompt(buildSubagentSysPrompt(spec.getSysPrompt())); - return sub.build(); + HarnessAgent child = sub.build(); + + if (!decl.getTools().isEmpty()) { + applyToolsAllowlist(child, decl.getTools()); + } + + return child; }; } + /** + * Resolves the runtime workspace root for a declared subagent according to the five-row + * decision table. Creates the auto-generated isolated directory when needed. + */ + private static Path resolveDeclaredWorkspace(SubagentDeclaration decl, Path mainWorkspace) { + if (decl.getWorkspacePath() != null) { + if (decl.getWorkspaceMode() == WorkspaceMode.SHARED) { + return mainWorkspace; + } + return decl.getWorkspacePath(); + } + if (decl.getWorkspaceMode() == WorkspaceMode.SHARED) { + return mainWorkspace; + } + // ISOLATED + no path → auto-create agents//workspace/ + Path isolated = + mainWorkspace.resolve("agents").resolve(decl.getName()).resolve("workspace"); + try { + Files.createDirectories(isolated); + } catch (Exception e) { + log.warn( + "Failed to create isolated workspace for subagent '{}' at {}: {}", + decl.getName(), + isolated, + e.getMessage()); + } + return isolated; + } + + /** + * Resolves the system-prompt base for a declared subagent. + * + *

    + *
  • Definition workspace present: reads {@code AGENTS.md} from the definition + * directory. Falls back to an empty string if the file is absent. + *
  • Inline: returns {@link SubagentDeclaration#getInlineAgentsBody()}. + *
+ * + *

The returned string is later combined with {@link #SUBAGENT_CONTEXT_SECTION} via + * {@link #buildSubagentSysPrompt(String)}. + */ + private static String resolveDeclaredSysPromptBase(SubagentDeclaration decl) { + if (decl.getWorkspacePath() != null) { + Path agentsMd = decl.getWorkspacePath().resolve("AGENTS.md"); + if (Files.isRegularFile(agentsMd)) { + try { + return Files.readString(agentsMd, java.nio.charset.StandardCharsets.UTF_8); + } catch (Exception e) { + log.warn( + "Failed to read AGENTS.md for subagent '{}' from {}: {}", + decl.getName(), + agentsMd, + e.getMessage()); + } + } + return ""; + } + String inline = decl.getInlineAgentsBody(); + return (inline != null) ? inline : ""; + } + + /** + * Resolves the effective {@link Model} for a subagent, applying the optional per-subagent + * model override. + */ + private static Model resolveModel( + String modelOverride, + Model parentModel, + Function resolver, + String subagentName) { + if (modelOverride == null || modelOverride.isBlank()) { + return parentModel; + } + Function effectiveResolver = + resolver != null ? resolver : ModelRegistry::resolve; + if (ModelRegistry.canResolve(modelOverride) || resolver != null) { + try { + Model resolved = effectiveResolver.apply(modelOverride); + if (resolved != null) { + log.debug( + "Subagent '{}' using overridden model: {}", + subagentName, + modelOverride); + return resolved; + } + } catch (Exception e) { + log.warn( + "Failed to resolve model '{}' for subagent '{}', falling back to" + + " parent model: {}", + modelOverride, + subagentName, + e.getMessage()); + } + } + return parentModel; + } + + /** + * Applies a tool allowlist to an already-built {@link HarnessAgent} by removing any tools + * whose names are not in the allowlist. + */ + private static void applyToolsAllowlist(HarnessAgent agent, List allowlist) { + Toolkit toolkit = agent.getDelegate().getToolkit(); + List toRemove = + toolkit.getToolSchemas().stream() + .map(ToolSchema::getName) + .filter(name -> !allowlist.contains(name)) + .toList(); + toRemove.forEach(toolkit::removeTool); + } + // ----------------------------------------------------------------- // Skills // ----------------------------------------------------------------- diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/RemoteFilesystemSpec.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/RemoteFilesystemSpec.java index 123870f06..808ad66eb 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/RemoteFilesystemSpec.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/filesystem/spec/RemoteFilesystemSpec.java @@ -143,6 +143,7 @@ public AbstractFilesystem toFilesystem( routes.put("MEMORY.md", shared); routes.put("memory/", shared); routes.put("agents/" + effectiveAgentId + "/sessions/", shared); + routes.put("agents/" + effectiveAgentId + "/tasks/", shared); for (String extra : extraSharedPrefixes) { routes.put(extra, shared); } 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 index b11519ed8..9b504a167 100644 --- 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 @@ -15,19 +15,26 @@ */ 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.PreReasoningEvent; +import io.agentscope.core.hook.RuntimeContextAware; import io.agentscope.harness.agent.subagent.DefaultAgentManager; import io.agentscope.harness.agent.subagent.SubagentFactory; +import io.agentscope.harness.agent.subagent.task.BackgroundTask; 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.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.util.Collection; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.function.Supplier; import java.util.stream.Collectors; import reactor.core.publisher.Mono; @@ -48,9 +55,16 @@ * 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. + *

  • Appends a concise summary of current async tasks to the system content each turn + * (at most 10 tasks), so the model always has current task state even after compaction. * */ -public class SubagentsHook implements Hook { +public class SubagentsHook implements Hook, RuntimeContextAware { + + private static final DateTimeFormatter ISO_SHORT = + DateTimeFormatter.ofPattern("HH:mm'Z'").withZone(ZoneOffset.UTC); + + private static final int MAX_TASK_SUMMARY_ENTRIES = 10; // @formatter:off private static final String SUBAGENT_SECTION_TEMPLATE = @@ -80,11 +94,23 @@ public class SubagentsHook implements Hook { ### 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_output`** — Retrieve the result of a background task by task_id. + - Prefer `block=false` to check status without blocking. + - Only use `block=true` (default) when ready to wait for the result. + - **Do NOT call immediately after launching** — the task has just started and will not be ready yet. **`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). + **`task_list`** — List all background tasks with their current, live statuses (reads from durable storage). + - Always accurate even after conversation compaction or node migration. + - Use after compaction or session resume to recover all task IDs and current state. + + ### CRITICAL async task rules + 1. **Never poll immediately** after launching a task. Return control to the user instead. + 2. **Never poll in a loop** — task_output does not short-circuit; every call blocks or waits. + 3. **Task status in conversation history is STALE** — do not report it. Always call `task_output(block=false)` or `task_list()` for the current state. + 4. After compaction or session resume, call `task_list()` first to recover all task IDs and statuses. + 5. For a single task status check, use `task_output(task_id=..., block=false)`. ### Available agent ids %s @@ -109,7 +135,7 @@ public class SubagentsHook implements Hook { 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 + - **Parallel execution**: Launch multiple subagents concurrently with timeout_seconds=0 when tasks are independent, then collect results with task_output(block=false) after a delay - **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 @@ -120,7 +146,9 @@ public class SubagentsHook implements Hook { private final List entries; private final Object subagentTool; private final TaskTool taskTool; + private final TaskRepository taskRepository; private final boolean isSessionMode; + private volatile RuntimeContext runtimeContext; /** * Default mode: creates {@link AgentSpawnTool} + {@link DefaultAgentManager} internally. @@ -128,17 +156,21 @@ public class SubagentsHook implements Hook { * @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 + * @param userIdSupplier provides the parent agent's current user-id at spawn time; may be + * {@code null} if userId propagation is not required */ public SubagentsHook( List entries, TaskRepository taskRepository, - WorkspaceManager workspaceManager) { + WorkspaceManager workspaceManager, + Supplier userIdSupplier) { 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.taskRepository = repo; + this.subagentTool = new AgentSpawnTool(dam, repo, 0, userIdSupplier); this.taskTool = new TaskTool(repo); } @@ -157,11 +189,17 @@ public SubagentsHook( this.isSessionMode = true; this.subagentTool = externalSubagentTool; TaskRepository repo = taskRepository != null ? taskRepository : new DefaultTaskRepository(); + this.taskRepository = repo; this.taskTool = new TaskTool(repo); } public SubagentsHook(List entries) { - this(entries, (TaskRepository) null, (WorkspaceManager) null); + this(entries, null, null, null); + } + + @Override + public void setRuntimeContext(RuntimeContext runtimeContext) { + this.runtimeContext = runtimeContext; } @Override @@ -202,6 +240,51 @@ private void injectSubagentPrompt(PreReasoningEvent event) { String.format(SUBAGENT_SECTION_TEMPLATE, spawnName, sendName, listName, agentList); event.appendSystemContent(section); + + // Per-turn async task summary (compact, at most MAX_TASK_SUMMARY_ENTRIES entries) + String taskSummary = buildTaskSummary(); + if (taskSummary != null) { + event.appendSystemContent(taskSummary); + } + } + + /** + * Builds a concise task summary string for the current session, or {@code null} if there are + * no tasks to report. The summary is injected into the system content every turn so the model + * always has current task IDs and statuses — even after conversation compaction. + */ + private String buildTaskSummary() { + if (taskRepository == null) { + return null; + } + String sessionId = runtimeContext != null ? runtimeContext.getSessionId() : null; + Collection tasks = taskRepository.listTasks(sessionId, null); + if (tasks.isEmpty()) { + return null; + } + + StringBuilder sb = new StringBuilder("\n### Async tasks (current session)\n"); + int count = 0; + for (BackgroundTask task : tasks) { + if (count >= MAX_TASK_SUMMARY_ENTRIES) { + sb.append("- ... (") + .append(tasks.size() - MAX_TASK_SUMMARY_ENTRIES) + .append(" more — use task_list() to see all)\n"); + break; + } + 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(" started: ").append(ISO_SHORT.format(task.getCreatedAt())); + sb.append('\n'); + count++; + } + sb.append( + "(Status above reflects current state; use task_output or task_list for" + + " latest.)\n"); + return sb.toString(); } private static Map buildFactories(List entries) { diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxExecutionGuard.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxExecutionGuard.java index c0cd541af..25b9cf435 100644 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxExecutionGuard.java +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/sandbox/SandboxExecutionGuard.java @@ -24,10 +24,11 @@ * {@link SandboxIsolationKey}. The default {@link #noop()} imposes no restriction, preserving * existing behaviour. * - *

    This extension point is primarily useful for {@link IsolationScope#AGENT} and - * {@link IsolationScope#GLOBAL} scopes, where multiple concurrent callers could otherwise - * race on the same persistent state slot (last write wins). Providing a guard serialises - * such callers without requiring changes to the surrounding infrastructure. + *

    This extension point is primarily useful for {@link IsolationScope#USER}, + * {@link IsolationScope#AGENT} and {@link IsolationScope#GLOBAL} scopes, where multiple + * concurrent callers could otherwise race on the same persistent state slot (last write wins). + * Providing a guard serialises such callers without requiring changes to the surrounding + * infrastructure. * *

    Implementations may use any backend — JVM semaphores, Redis {@code SET NX} leases, * ZooKeeper, database advisory locks, etc. — and must be thread-safe. 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 index 608bfc094..f6a45a2b5 100644 --- 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 @@ -30,33 +30,41 @@ import org.slf4j.LoggerFactory; /** - * Loads {@link SubagentSpec} definitions from Markdown files with YAML front matter. Compatible - * with Spring AI agent spec format. + * Loads {@link SubagentDeclaration} instances from Markdown files with YAML front matter placed + * in the {@code subagents/} directory of a workspace. + * + *

    File naming: the filename (without the {@code .md} extension) becomes the + * subagent's {@code name} / agent-id. The front matter must not contain a {@code name} field. + * + *

    Scan strategy: only direct children of the given directory are + * scanned (non-recursive) to prevent accidentally loading files that live inside a definition + * workspace that happens to be stored under the same parent. * *

    File format: * *

      * ---
    - * name: Explore
    - * description: Fast agent for exploring codebases...
    - * tools: Read, Grep, Glob
    + * description: Reviews code for security and performance issues.
    + * workspace:
    + *   mode: isolated          # isolated | shared  (default: isolated)
    + *   path: ./defs/reviewer   # optional; relative to mainWorkspace, or absolute
    + * model: qwen3-max          # optional model override
    + * maxIters: 12              # optional (default 10)
    + * tools: [read_file, grep_files, edit_file]   # optional allowlist
      * ---
      *
    - * # System prompt (markdown body)
    - * You are a file search specialist...
    + * # Inline body (only when workspace.path is absent)
    + * You are a code reviewer...
      * 
    * - *

    Front matter fields: - * + *

    Rules: *

      - *
    • {@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) + *
    • {@code description} is required. + *
    • When {@code workspace.path} is present, the Markdown body must be blank; the subagent's + * system prompt is read from {@code <workspace.path>/AGENTS.md} at runtime. + *
    • When {@code workspace.path} is absent, the Markdown body (if any) becomes the inline + * {@link SubagentDeclaration#getInlineAgentsBody()}. *
    - * - *

    The Markdown body becomes the system prompt. */ public final class AgentSpecLoader { @@ -65,96 +73,194 @@ public final class AgentSpecLoader { 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)) { + /** + * Non-recursively scans {@code subagentsDir} for {@code .md} files and parses each into a + * {@link SubagentDeclaration}. + * + * @param subagentsDir the {@code subagents/} directory to scan + * @param mainWorkspace the parent workspace used to resolve relative {@code workspace.path} + * values; may be {@code null} (relative paths will remain relative) + * @return list of parsed declarations; never {@code null} + */ + public static List loadFromDirectory( + Path subagentsDir, Path mainWorkspace) { + if (subagentsDir == null || !Files.isDirectory(subagentsDir)) { return Collections.emptyList(); } - List specs = new ArrayList<>(); - try (Stream paths = Files.walk(rootPath)) { + List decls = new ArrayList<>(); + try (Stream paths = Files.list(subagentsDir)) { paths.filter(Files::isRegularFile) .filter(p -> p.getFileName().toString().endsWith(".md")) + .sorted() .forEach( path -> { try { - SubagentSpec spec = loadFromFile(path); - if (spec != null) { - specs.add(spec); + SubagentDeclaration decl = loadFromFile(path, mainWorkspace); + if (decl != null) { + decls.add(decl); log.debug( - "Loaded agent spec '{}' from {}", - spec.getName(), + "Loaded subagent declaration '{}' from {}", + decl.getName(), path); } } catch (Exception e) { log.warn( - "Failed to load agent spec from {}: {}", + "Failed to load subagent declaration from {}: {}", path, e.getMessage()); } }); } catch (IOException e) { - log.warn("Failed to walk directory {}: {}", rootPath, e.getMessage()); + log.warn("Failed to list subagents directory {}: {}", subagentsDir, e.getMessage()); } - return specs; + return decls; } - public static SubagentSpec loadFromFile(Path filePath) throws IOException { + /** + * Parses a single Markdown declaration file. + * + * @param filePath the {@code .md} file to parse + * @param mainWorkspace workspace root used to resolve relative {@code workspace.path} values; + * may be {@code null} + * @return parsed declaration, or {@code null} if the file is malformed / missing required + * fields + */ + public static SubagentDeclaration loadFromFile(Path filePath, Path mainWorkspace) + throws IOException { String content = Files.readString(filePath, StandardCharsets.UTF_8); - return parse(content); + String nameFromFile = stripMdExtension(filePath.getFileName().toString()); + return parse(content, nameFromFile, mainWorkspace); } /** - * Parses markdown content with YAML front matter into a {@link SubagentSpec}. + * Parses markdown content with YAML front matter into a {@link SubagentDeclaration}. * - * @return parsed spec, or null if the content is malformed + * @param markdown the full file content + * @param name the subagent name (derived from the filename, without {@code .md}) + * @param mainWorkspace workspace root used to resolve relative {@code workspace.path} values; + * may be {@code null} + * @return parsed declaration, or {@code null} if the content is malformed */ @SuppressWarnings("unchecked") - public static SubagentSpec parse(String markdown) { + public static SubagentDeclaration parse(String markdown, String name, Path mainWorkspace) { 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 ---"); + log.warn("Agent declaration front matter not closed with --- in '{}'", name); return null; } String frontMatterStr = markdown.substring(3, endIdx).trim(); String body = markdown.substring(endIdx + 3).trim(); - Map frontMatter; + Map fm; try { - frontMatter = YAML_MAPPER.readValue(frontMatterStr, Map.class); + fm = YAML_MAPPER.readValue(frontMatterStr, Map.class); } catch (Exception e) { - log.warn("Failed to parse YAML front matter: {}", e.getMessage()); + log.warn("Failed to parse YAML front matter for '{}': {}", name, e.getMessage()); return null; } - if (frontMatter == null || frontMatter.isEmpty()) { + if (fm == null || fm.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; - } + String description = asString(fm.get("description")); if (description == null || description.isBlank()) { - log.warn("Agent spec missing required 'description' in front matter"); + log.warn( + "Subagent declaration '{}' missing required 'description' in front matter", + name); 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"))); + // ---- workspace section ---- + WorkspaceMode mode = WorkspaceMode.ISOLATED; + Path workspacePath = null; + + Object workspaceObj = fm.get("workspace"); + if (workspaceObj instanceof Map wsMap) { + String modeStr = asString(wsMap.get("mode")); + if (modeStr != null) { + if ("shared".equalsIgnoreCase(modeStr)) { + mode = WorkspaceMode.SHARED; + } else if ("isolated".equalsIgnoreCase(modeStr)) { + mode = WorkspaceMode.ISOLATED; + } else { + log.warn( + "Unknown workspace.mode '{}' in declaration '{}', defaulting to" + + " isolated", + modeStr, + name); + } + } + String pathStr = asString(wsMap.get("path")); + if (pathStr != null && !pathStr.isBlank()) { + workspacePath = resolvePath(pathStr, mainWorkspace); + } + } + + // ---- body / inline sysPrompt validation ---- + if (workspacePath != null && !body.isBlank()) { + log.warn( + "Subagent declaration '{}' has both workspace.path and a non-empty body;" + + " body will be ignored — put the system prompt in" + + " {}/AGENTS.md instead", + name, + workspacePath); + body = ""; + } + + // ---- optional fields ---- + String model = asString(fm.get("model")); - Object maxItersObj = frontMatter.get("maxIters"); + int maxIters = 10; + Object maxItersObj = fm.get("maxIters"); if (maxItersObj instanceof Number n) { - spec.setMaxIters(n.intValue()); + maxIters = n.intValue(); + } + + List tools = parseToolNames(asString(fm.get("tools"))); + + SubagentDeclaration.Builder builder = + SubagentDeclaration.builder() + .name(name) + .description(description) + .workspaceMode(mode) + .model(model) + .maxIters(maxIters) + .tools(tools.isEmpty() ? null : tools); + + if (workspacePath != null) { + builder.workspace(workspacePath); + } else { + builder.inlineAgentsBody(body.isEmpty() ? null : body); } - return spec; + return builder.build(); + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + private static String stripMdExtension(String filename) { + if (filename.endsWith(".md")) { + return filename.substring(0, filename.length() - 3); + } + return filename; + } + + private static Path resolvePath(String pathStr, Path mainWorkspace) { + Path p = Path.of(pathStr); + if (p.isAbsolute()) { + return p; + } + if (mainWorkspace != null) { + Path resolved = mainWorkspace.resolve(p).normalize(); + return resolved; + } + return p; } private static String asString(Object v) { @@ -165,6 +271,11 @@ 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(); + // Support both comma-separated strings and YAML list representations "[a, b, c]" + String cleaned = toolsStr.stripLeading(); + if (cleaned.startsWith("[") && cleaned.endsWith("]")) { + cleaned = cleaned.substring(1, cleaned.length() - 1); + } + return Stream.of(cleaned.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 index 50c6e32ca..d9b30477a 100644 --- 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 @@ -72,10 +72,20 @@ public Agent createAgent(String agentId) { /** * Invokes an agent with a user prompt. Handles both plain {@link Agent} and {@link * HarnessAgent} (injects {@link RuntimeContext} for the latter). + * + *

    For {@link HarnessAgent} children, {@code userId} is propagated so that isolation-key + * resolution (e.g. {@code USER}-scoped sandbox slots) works correctly. A fresh {@code + * sessionId} is always assigned independently of the parent session. + * + * @param agent the agent to invoke + * @param sessionId a new, child-specific session id + * @param userId the parent's user-id (may be {@code null}) + * @param prompt the user message to send */ - public Mono invokeAgent(Agent agent, String sessionId, String prompt) { + public Mono invokeAgent(Agent agent, String sessionId, String userId, String prompt) { if (agent instanceof HarnessAgent harness) { - RuntimeContext ctx = RuntimeContext.builder().sessionId(sessionId).build(); + RuntimeContext ctx = + RuntimeContext.builder().sessionId(sessionId).userId(userId).build(); return harness.call(userMessage(prompt), ctx); } return agent.call(List.of(userMessage(prompt))); diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentDeclaration.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentDeclaration.java new file mode 100644 index 000000000..905d58dea --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentDeclaration.java @@ -0,0 +1,266 @@ +/* + * 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 java.nio.file.Path; +import java.util.List; + +/** + * Declares a subagent: its identity, workspace resolution strategy, and optional capability + * allowlist. + * + *

    A declaration binds to exactly one of two source modes: + * + *

      + *
    1. Definition workspace — {@link #getWorkspacePath()} points to a workspace directory + * containing at least {@code AGENTS.md}. That file is used as the subagent's system-prompt + * body. Skills, knowledge, and MEMORY in the definition directory are available when the + * {@link WorkspaceMode} is {@link WorkspaceMode#ISOLATED}. + *
    2. Inline — no external workspace; {@link #getInlineAgentsBody()} is the system-prompt + * body directly (equivalent to writing the body in a {@code subagents/<name>.md} file + * with no {@code workspace.path} in the front matter). + *
    + * + *

    The two source modes are mutually exclusive: setting both {@link Builder#workspace(Path)} and + * a non-blank {@link Builder#inlineAgentsBody(String)} is rejected at build time. + * + *

    Workspace resolution follows the five-row decision table in {@link WorkspaceMode}. + * + *

    The {@code tools} list, when non-empty, acts as an allowlist filter: only tools + * whose names appear in the list are kept on the subagent's toolkit. It cannot add tools that the + * parent agent does not have. + * + *

    Obtain instances via {@link #builder()}. + * + *

    Example (programmatic): + * + *

    {@code
    + * SubagentDeclaration decl = SubagentDeclaration.builder()
    + *     .name("code-reviewer")
    + *     .description("Reviews code for security, performance, and readability issues.")
    + *     .workspace(Path.of("./defs/code-reviewer"))
    + *     .workspaceMode(WorkspaceMode.ISOLATED)
    + *     .model("qwen3-max")
    + *     .tools(List.of("read_file", "grep_files", "edit_file"))
    + *     .build();
    + * }
    + */ +public final class SubagentDeclaration { + + private final String name; + private final String description; + private final WorkspaceMode workspaceMode; + private final Path workspacePath; + private final String inlineAgentsBody; + private final String model; + private final int maxIters; + private final List tools; + + private SubagentDeclaration(Builder b) { + this.name = b.name; + this.description = b.description; + this.workspaceMode = b.workspaceMode; + this.workspacePath = b.workspacePath; + this.inlineAgentsBody = b.inlineAgentsBody; + this.model = b.model; + this.maxIters = b.maxIters; + this.tools = b.tools != null ? List.copyOf(b.tools) : List.of(); + } + + /** Factory method for a new builder. */ + public static Builder builder() { + return new Builder(); + } + + /** Unique name / agent-id used to reference this subagent. */ + public String getName() { + return name; + } + + /** Human-readable description; the main agent uses this to decide when to delegate. */ + public String getDescription() { + return description; + } + + /** + * Workspace resolution strategy. Defaults to {@link WorkspaceMode#ISOLATED} when not + * specified. + */ + public WorkspaceMode getWorkspaceMode() { + return workspaceMode; + } + + /** + * Path to the definition workspace directory (contains at least {@code AGENTS.md}). When + * {@code null} this declaration is in inline mode and {@link #getInlineAgentsBody()} provides + * the system prompt. + */ + public Path getWorkspacePath() { + return workspacePath; + } + + /** + * Inline system-prompt body used when {@link #getWorkspacePath()} is {@code null}. May be + * {@code null} or blank if neither a definition workspace nor an inline body is provided. + */ + public String getInlineAgentsBody() { + return inlineAgentsBody; + } + + /** + * Optional model override (e.g. {@code "qwen3-max"} or {@code "openai:gpt-4o-mini"}). When + * {@code null} or blank, the parent model is used. + */ + public String getModel() { + return model; + } + + /** Maximum reasoning iterations. Defaults to 10. */ + public int getMaxIters() { + return maxIters; + } + + /** + * Optional tool allowlist. When non-empty, only the listed tool names remain on the subagent's + * toolkit. Empty means inherit all tools from the parent configuration. + */ + public List getTools() { + return tools; + } + + /** Returns {@code true} when this declaration points at an external definition workspace. */ + public boolean hasDefinitionWorkspace() { + return workspacePath != null; + } + + // ------------------------------------------------------------------------- + // Builder + // ------------------------------------------------------------------------- + + public static final class Builder { + + private String name; + private String description; + private WorkspaceMode workspaceMode = WorkspaceMode.ISOLATED; + private Path workspacePath; + private String inlineAgentsBody; + private String model; + private int maxIters = 10; + private List tools; + + private Builder() {} + + /** Sets the unique name / agent-id for this subagent (required). */ + public Builder name(String name) { + this.name = name; + return this; + } + + /** + * Sets the human-readable description the orchestrator uses to decide when to delegate + * (required). + */ + public Builder description(String description) { + this.description = description; + return this; + } + + /** + * Sets the workspace resolution mode. Defaults to {@link WorkspaceMode#ISOLATED}. + * + * @param mode workspace mode; {@code null} is treated as {@link WorkspaceMode#ISOLATED} + */ + public Builder workspaceMode(WorkspaceMode mode) { + this.workspaceMode = mode != null ? mode : WorkspaceMode.ISOLATED; + return this; + } + + /** + * Points this declaration at an external definition workspace. + * + *

    Mutually exclusive with {@link #inlineAgentsBody(String)}: passing both a non-null + * path and a non-blank inline body will cause {@link #build()} to throw. + * + * @param workspacePath absolute path, or path relative to {@code mainWorkspace} when set + * via a Markdown front matter file + */ + public Builder workspace(Path workspacePath) { + this.workspacePath = workspacePath; + return this; + } + + /** + * Sets the inline system-prompt body for lightweight subagents that do not need a + * dedicated definition workspace. + * + *

    Mutually exclusive with {@link #workspace(Path)}. + * + * @param body the system-prompt body text (Markdown); may be {@code null} or blank + */ + public Builder inlineAgentsBody(String body) { + this.inlineAgentsBody = body; + return this; + } + + /** + * Optional model override resolved via {@link io.agentscope.core.model.ModelRegistry}. + * Falls back to the parent model when blank or unresolvable. + */ + public Builder model(String model) { + this.model = model; + return this; + } + + /** Maximum reasoning iterations (default 10). */ + public Builder maxIters(int maxIters) { + this.maxIters = maxIters; + return this; + } + + /** + * Tool allowlist: when non-empty, only the listed tool names are kept on the subagent's + * toolkit. Cannot grant tools that the parent does not have. + */ + public Builder tools(List tools) { + this.tools = tools; + return this; + } + + /** + * Builds the {@link SubagentDeclaration}. + * + * @throws IllegalArgumentException if {@code name} or {@code description} is blank, or + * both {@code workspace(Path)} and a non-blank {@code inlineAgentsBody()} are set + */ + public SubagentDeclaration build() { + if (name == null || name.isBlank()) { + throw new IllegalArgumentException("SubagentDeclaration requires a non-blank name"); + } + if (description == null || description.isBlank()) { + throw new IllegalArgumentException( + "SubagentDeclaration requires a non-blank description"); + } + if (workspacePath != null && inlineAgentsBody != null && !inlineAgentsBody.isBlank()) { + throw new IllegalArgumentException( + "workspace(Path) and inlineAgentsBody() are mutually exclusive;" + + " set at most one for subagent '" + + name + + "'"); + } + return new SubagentDeclaration(this); + } + } +} 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 deleted file mode 100644 index aa1966b93..000000000 --- a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/SubagentSpec.java +++ /dev/null @@ -1,124 +0,0 @@ -/* - * 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/WorkspaceMode.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/WorkspaceMode.java new file mode 100644 index 000000000..e44e5e868 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/WorkspaceMode.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.subagent; + +/** + * Controls how a declared subagent's runtime workspace root is determined. + * + *

    The five-row decision table: + * + *

    + * workspacePath  mode      runtime-workspace-root
    + * ─────────────────────────────────────────────────────────────────────────────
    + * set            ISOLATED  workspacePath  (definition dir is also the runtime root)
    + * set            SHARED    mainWorkspace  (definition skills/knowledge ignored)
    + * null           ISOLATED  mainWorkspace/agents/<name>/workspace/  (auto-created)
    + * null           SHARED    mainWorkspace
    + * (general-purpose, always SHARED)       mainWorkspace  (fully mirrors main agent)
    + * 
    + */ +public enum WorkspaceMode { + + /** + * The subagent gets its own isolated workspace. + * + *
      + *
    • If {@link SubagentDeclaration#getWorkspacePath()} is set, that path is the runtime + * root and also the source for the sysPrompt ({@code AGENTS.md}). + *
    • Otherwise the runtime root is auto-created at + * {@code mainWorkspace/agents/<name>/workspace/} and the inline body is used as + * sysPrompt. + *
    + */ + ISOLATED, + + /** + * The subagent shares the main agent's workspace. + * + *
      + *
    • The runtime root is always {@code mainWorkspace}, regardless of + * {@link SubagentDeclaration#getWorkspacePath()}. + *
    • If {@code workspacePath} is set, its {@code AGENTS.md} is used as the sysPrompt body; + * but the definition's {@code skills/}, {@code knowledge/}, and {@code MEMORY.md} are + * ignored. + *
    • If {@code workspacePath} is absent, the inline body is used as sysPrompt. + *
    + */ + SHARED +} 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 index e8ac3dea0..5ab2eaa15 100644 --- 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 @@ -27,8 +27,11 @@ 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}. + * In-memory {@link TaskRepository} backed by a cached daemon thread pool. + * + *

    Session IDs are ignored — all tasks share a single flat map. This is suitable for + * single-node local deployments and testing. For distributed durability, prefer + * {@code WorkspaceTaskRepository}. */ public class DefaultTaskRepository implements TaskRepository { @@ -58,20 +61,21 @@ private DefaultTaskRepository(ExecutorService executor, boolean ownsExecutor) { } @Override - public BackgroundTask getTask(String taskId) { + public BackgroundTask getTask(String sessionId, String taskId) { return tasks.get(taskId); } @Override - public BackgroundTask putTask(String taskId, String agentId, Supplier taskExecution) { + public BackgroundTask putTask( + String taskId, String subAgentId, String sessionId, Supplier taskExecution) { CompletableFuture future = CompletableFuture.supplyAsync(taskExecution, executor); - BackgroundTask task = new BackgroundTask(taskId, agentId, future); + BackgroundTask task = new BackgroundTask(taskId, subAgentId, future); tasks.put(taskId, task); return task; } @Override - public void removeTask(String taskId) { + public void removeTask(String sessionId, String taskId) { tasks.remove(taskId); } @@ -81,7 +85,7 @@ public void clear() { } @Override - public Collection listTasks(TaskStatus filter) { + public Collection listTasks(String sessionId, TaskStatus filter) { if (filter == null) { return List.copyOf(tasks.values()); } @@ -95,7 +99,7 @@ public Collection listTasks(TaskStatus filter) { } @Override - public boolean cancelTask(String taskId) { + public boolean cancelTask(String sessionId, String taskId) { BackgroundTask task = tasks.get(taskId); if (task == null) { return false; diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/TaskRecord.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/TaskRecord.java new file mode 100644 index 000000000..6376b532f --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/TaskRecord.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.subagent.task; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonInclude; +import java.time.Instant; + +/** + * Persistent metadata for a background subagent task, stored as JSON in the workspace under + * {@code agents//tasks/.json}. + * + *

    This is the authoritative truth source for task state. In-memory {@link BackgroundTask} + * handles are a local performance overlay; {@code TaskRecord} survives across JVM restarts and + * request migrations in distributed deployments. + * + *

    All fields use Jackson for JSON serialization. Unknown fields are silently ignored to + * allow forward-compatible schema evolution. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class TaskRecord { + + private String taskId; + private String subAgentId; + private String parentAgentId; + private String parentSessionId; + private String subSessionId; + private TaskStatus status; + private String result; + private String errorMessage; + private boolean cancelRequested; + private Instant createdAt; + private Instant lastCheckedAt; + private Instant lastUpdatedAt; + + public TaskRecord() {} + + public TaskRecord( + String taskId, + String subAgentId, + String parentAgentId, + String parentSessionId, + String subSessionId) { + this.taskId = taskId; + this.subAgentId = subAgentId; + this.parentAgentId = parentAgentId; + this.parentSessionId = parentSessionId; + this.subSessionId = subSessionId; + this.status = TaskStatus.PENDING; + this.cancelRequested = false; + Instant now = Instant.now(); + this.createdAt = now; + this.lastCheckedAt = now; + this.lastUpdatedAt = now; + } + + public String getTaskId() { + return taskId; + } + + public void setTaskId(String taskId) { + this.taskId = taskId; + } + + public String getSubAgentId() { + return subAgentId; + } + + public void setSubAgentId(String subAgentId) { + this.subAgentId = subAgentId; + } + + public String getParentAgentId() { + return parentAgentId; + } + + public void setParentAgentId(String parentAgentId) { + this.parentAgentId = parentAgentId; + } + + public String getParentSessionId() { + return parentSessionId; + } + + public void setParentSessionId(String parentSessionId) { + this.parentSessionId = parentSessionId; + } + + public String getSubSessionId() { + return subSessionId; + } + + public void setSubSessionId(String subSessionId) { + this.subSessionId = subSessionId; + } + + public TaskStatus getStatus() { + return status; + } + + public void setStatus(TaskStatus status) { + this.status = status; + } + + public String getResult() { + return result; + } + + public void setResult(String result) { + this.result = result; + } + + public String getErrorMessage() { + return errorMessage; + } + + public void setErrorMessage(String errorMessage) { + this.errorMessage = errorMessage; + } + + public boolean isCancelRequested() { + return cancelRequested; + } + + public void setCancelRequested(boolean cancelRequested) { + this.cancelRequested = cancelRequested; + } + + public Instant getCreatedAt() { + return createdAt; + } + + public void setCreatedAt(Instant createdAt) { + this.createdAt = createdAt; + } + + public Instant getLastCheckedAt() { + return lastCheckedAt; + } + + public void setLastCheckedAt(Instant lastCheckedAt) { + this.lastCheckedAt = lastCheckedAt; + } + + public Instant getLastUpdatedAt() { + return lastUpdatedAt; + } + + public void setLastUpdatedAt(Instant lastUpdatedAt) { + this.lastUpdatedAt = lastUpdatedAt; + } + + /** Convenience: update lastUpdatedAt to now. */ + public void touch() { + this.lastUpdatedAt = Instant.now(); + } + + /** Convenience: update lastCheckedAt to now. */ + public void touchChecked() { + this.lastCheckedAt = Instant.now(); + } +} 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 index 0fafc1e66..216da6b20 100644 --- 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 @@ -19,39 +19,57 @@ import java.util.function.Supplier; /** - * Repository for managing background subagent tasks. Supports async execution with retrieval by - * task ID, listing, and cancellation. + * Repository for managing background subagent tasks, scoped by session. + * + *

    All operations are scoped to a {@code sessionId} so that tasks from different parent sessions + * are isolated from one another. Implementations may ignore {@code sessionId} (in-memory stores) + * or use it to partition durable storage (workspace-backed stores). */ public interface TaskRepository { - /** Retrieve a background task by its ID, or null if not found. */ - BackgroundTask getTask(String taskId); + /** + * Retrieve a background task by session and task ID, or {@code null} if not found. + * + * @param sessionId the parent session scope + * @param taskId unique task identifier + */ + BackgroundTask getTask(String sessionId, 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 subAgentId the subagent type executing this task + * @param sessionId the parent session scope * @param taskExecution the work to execute asynchronously * @return the created background task */ - BackgroundTask putTask(String taskId, String agentId, Supplier taskExecution); + BackgroundTask putTask( + String taskId, String subAgentId, String sessionId, Supplier taskExecution); - void removeTask(String taskId); + /** + * Remove a task from the repository. + * + * @param sessionId the parent session scope + * @param taskId unique task identifier + */ + void removeTask(String sessionId, String taskId); + /** Clear all tasks across all sessions. */ void clear(); /** - * List all tracked tasks, optionally filtered by status. + * List all tracked tasks for the given session, optionally filtered by status. * + * @param sessionId the parent session scope * @param filter if non-null, only return tasks with this status; null returns all tasks */ - Collection listTasks(TaskStatus filter); + Collection listTasks(String sessionId, TaskStatus filter); /** - * Cancel a running task by its ID. + * Cancel a running task by session and task ID. * * @return true if the task was found and cancellation was attempted */ - boolean cancelTask(String taskId); + boolean cancelTask(String sessionId, String taskId); } diff --git a/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/WorkspaceTaskRepository.java b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/WorkspaceTaskRepository.java new file mode 100644 index 000000000..76cbd1582 --- /dev/null +++ b/agentscope-harness/src/main/java/io/agentscope/harness/agent/subagent/task/WorkspaceTaskRepository.java @@ -0,0 +1,316 @@ +/* + * 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 io.agentscope.harness.agent.workspace.WorkspaceManager; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +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; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Workspace-backed {@link TaskRepository} that uses {@link WorkspaceManager} as the authoritative + * truth source for task state, while maintaining in-memory {@link BackgroundTask} handles as a + * local performance overlay for tasks running on the current node. + * + *

    Storage layout: {@code agents//tasks/.json} — a JSON map of + * {@code taskId → TaskRecord}, consistent with how sessions are stored. In distributed deployments + * using {@code RemoteFilesystemSpec}, this path is automatically routed to shared storage, making + * task state visible to any node. + * + *

    The in-memory {@code localTasks} map is keyed by {@code ":"} to preserve + * session isolation when multiple sessions coexist in the same process. + * + *

    Distributed semantics: + * + *

      + *
    • Task execution is sticky to the originating node (the node that called {@link #putTask}). + *
    • Any node can read task status via {@link #getTask} or {@link #listTasks} by falling back + * to workspace records when no local future exists. + *
    • {@code block=true} on a non-originating node degrades gracefully to reading the latest + * persisted terminal state without hanging. + *
    • Cancellation sets a {@link TaskRecord#isCancelRequested()} flag in workspace storage; + * the originating node checks this flag before invoking the subagent for best-effort cancel. + *
    + */ +public class WorkspaceTaskRepository implements TaskRepository { + + private static final Logger log = LoggerFactory.getLogger(WorkspaceTaskRepository.class); + + private final WorkspaceManager workspaceManager; + private final String parentAgentId; + + /** + * In-memory local task handles. Keyed by {@code ":"} to provide session + * isolation when multiple parent sessions are active in the same JVM process. + */ + private final Map localTasks = new ConcurrentHashMap<>(); + + private final ExecutorService executor; + private final boolean ownsExecutor; + + public WorkspaceTaskRepository(WorkspaceManager workspaceManager, String parentAgentId) { + this( + workspaceManager, + parentAgentId, + Executors.newCachedThreadPool( + r -> { + Thread t = new Thread(r); + t.setDaemon(true); + t.setName("ws-task-" + t.getId()); + return t; + }), + true); + } + + public WorkspaceTaskRepository( + WorkspaceManager workspaceManager, String parentAgentId, ExecutorService executor) { + this(workspaceManager, parentAgentId, executor, false); + } + + private WorkspaceTaskRepository( + WorkspaceManager workspaceManager, + String parentAgentId, + ExecutorService executor, + boolean ownsExecutor) { + this.workspaceManager = workspaceManager; + this.parentAgentId = parentAgentId != null ? parentAgentId : "HarnessAgent"; + this.executor = executor; + this.ownsExecutor = ownsExecutor; + } + + @Override + public BackgroundTask putTask( + String taskId, String subAgentId, String sessionId, Supplier taskExecution) { + // Write PENDING record first so cancelTask can find the task in workspace immediately + TaskRecord record = new TaskRecord(taskId, subAgentId, parentAgentId, sessionId, null); + record.setStatus(TaskStatus.PENDING); + persistRecord(sessionId, record); + + String localKey = localKey(sessionId, taskId); + CompletableFuture future = + CompletableFuture.supplyAsync( + () -> { + // Check cancel-before-start (cross-node cancellation coordination) + Optional latest = + workspaceManager.readTaskRecord( + parentAgentId, sessionId, taskId); + if (latest.isPresent() && latest.get().isCancelRequested()) { + markCancelled(sessionId, taskId); + return null; + } + + updateStatus(sessionId, taskId, TaskStatus.RUNNING, null, null); + try { + String result = taskExecution.get(); + Optional afterRun = + workspaceManager.readTaskRecord( + parentAgentId, sessionId, taskId); + if (afterRun.isPresent() && afterRun.get().isCancelRequested()) { + markCancelled(sessionId, taskId); + return null; + } + updateStatus(sessionId, taskId, TaskStatus.COMPLETED, result, null); + return result; + } catch (Exception e) { + String errMsg = + e.getMessage() != null + ? e.getMessage() + : e.getClass().getSimpleName(); + updateStatus(sessionId, taskId, TaskStatus.FAILED, null, errMsg); + throw e instanceof RuntimeException re + ? re + : new RuntimeException(e); + } + }, + executor); + + BackgroundTask bgTask = new BackgroundTask(taskId, subAgentId, future); + localTasks.put(localKey, bgTask); + return bgTask; + } + + @Override + public BackgroundTask getTask(String sessionId, String taskId) { + BackgroundTask local = localTasks.get(localKey(sessionId, taskId)); + if (local != null) { + return local; + } + // Fall back to workspace record — construct a synthetic completed/failed BackgroundTask + Optional record = + workspaceManager.readTaskRecord(parentAgentId, sessionId, taskId); + return record.map(this::syntheticTask).orElse(null); + } + + @Override + public Collection listTasks(String sessionId, TaskStatus filter) { + Collection records = workspaceManager.listTaskRecords(parentAgentId, sessionId); + + List result = new ArrayList<>(); + for (TaskRecord wsRecord : records) { + String key = localKey(sessionId, wsRecord.getTaskId()); + BackgroundTask local = localTasks.get(key); + // Use local handle if available (live status); otherwise fall back to workspace record. + // Never override a terminal workspace status with a local RUNNING state from an old + // handle. + BackgroundTask effective; + if (local != null && !wsRecord.getStatus().isTerminal()) { + effective = local; + } else { + effective = syntheticTask(wsRecord); + } + if (filter == null || effective.getTaskStatus() == filter) { + result.add(effective); + } + } + return result; + } + + @Override + public boolean cancelTask(String sessionId, String taskId) { + boolean found = false; + + BackgroundTask local = localTasks.get(localKey(sessionId, taskId)); + if (local != null) { + local.cancel(true); + found = true; + } + + // Always write cancelRequested flag to workspace for cross-node coordination + Optional existing = + workspaceManager.readTaskRecord(parentAgentId, sessionId, taskId); + if (existing.isPresent()) { + TaskRecord record = existing.get(); + record.setCancelRequested(true); + if (!record.getStatus().isTerminal()) { + record.setStatus(TaskStatus.CANCELLED); + } + persistRecord(sessionId, record); + return true; + } + + return found; + } + + @Override + public void removeTask(String sessionId, String taskId) { + localTasks.remove(localKey(sessionId, taskId)); + } + + @Override + public void clear() { + localTasks.clear(); + } + + /** Shuts down the executor if owned by this repository. */ + 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(); + } + } + } + + // ---- private helpers ---- + + private static String localKey(String sessionId, String taskId) { + String s = sessionId != null ? sessionId : "_"; + return s + ":" + taskId; + } + + private void persistRecord(String sessionId, TaskRecord record) { + try { + workspaceManager.writeTaskRecord(parentAgentId, sessionId, record); + } catch (Exception e) { + log.warn( + "Failed to persist task record {} for session {}: {}", + record.getTaskId(), + sessionId, + e.getMessage()); + } + } + + private void updateStatus( + String sessionId, String taskId, TaskStatus status, String result, String error) { + Optional existing = + workspaceManager.readTaskRecord(parentAgentId, sessionId, taskId); + TaskRecord record = + existing.orElseGet( + () -> { + TaskRecord r = new TaskRecord(); + r.setTaskId(taskId); + r.setParentAgentId(parentAgentId); + r.setParentSessionId(sessionId); + return r; + }); + record.setStatus(status); + if (result != null) { + record.setResult(result); + } + if (error != null) { + record.setErrorMessage(error); + } + persistRecord(sessionId, record); + } + + private void markCancelled(String sessionId, String taskId) { + updateStatus(sessionId, taskId, TaskStatus.CANCELLED, null, null); + } + + /** + * Creates a synthetic {@link BackgroundTask} from a persisted {@link TaskRecord}. The future + * is already-completed (or failed/cancelled) to reflect the stored terminal status. + */ + private BackgroundTask syntheticTask(TaskRecord record) { + CompletableFuture future; + switch (record.getStatus()) { + case COMPLETED -> future = CompletableFuture.completedFuture(record.getResult()); + case FAILED -> { + future = new CompletableFuture<>(); + future.completeExceptionally( + new RuntimeException( + record.getErrorMessage() != null + ? record.getErrorMessage() + : "Task failed")); + } + case CANCELLED -> { + future = new CompletableFuture<>(); + future.cancel(false); + } + default -> { + // PENDING or RUNNING but no local future — represents a cross-node task. + // Return an incomplete future so callers see "still running". + future = new CompletableFuture<>(); + } + } + return new BackgroundTask(record.getTaskId(), record.getSubAgentId(), future); + } +} 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 index 36a4c24b4..f38d7461d 100644 --- 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 @@ -16,6 +16,7 @@ package io.agentscope.harness.agent.tool; import io.agentscope.core.agent.Agent; +import io.agentscope.core.agent.RuntimeContext; import io.agentscope.core.message.Msg; import io.agentscope.core.tool.Tool; import io.agentscope.core.tool.ToolParam; @@ -25,6 +26,7 @@ import java.util.Objects; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Supplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -39,6 +41,10 @@ * *

    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. + * + *

    Async tasks ({@code timeout_seconds=0}) are submitted to the {@link TaskRepository} scoped + * by the current session ID from {@link RuntimeContext}. This makes task state visible in + * workspace storage for cross-node retrieval and recovery after compaction. */ public class AgentSpawnTool { @@ -52,13 +58,15 @@ public class AgentSpawnTool { """ 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.\ + Use task_output(task_id='%s', block=false) to check status, \ + task_cancel(task_id='%s') to cancel, or task_list() to see all tasks. \ + Do NOT call task_output immediately — the task has just started.\ """; private final DefaultAgentManager agentManager; private final TaskRepository taskRepository; private final int parentSpawnDepth; + private final Supplier userIdSupplier; private record SpawnedAgent( String key, String agentId, String sessionId, String label, Agent agent, int depth) {} @@ -66,11 +74,23 @@ private record SpawnedAgent( private final ConcurrentHashMap agentsByKey = new ConcurrentHashMap<>(); private final ConcurrentHashMap labelToKey = new ConcurrentHashMap<>(); + /** + * Creates an {@code AgentSpawnTool} with a supplier for the parent agent's current user-id. + * + * @param agentManager factory and invoker for subagents + * @param taskRepository background task store + * @param parentSpawnDepth current spawn-depth of the parent (0 for top-level main agent) + * @param userIdSupplier provides the parent's current user-id at spawn time (may return null) + */ public AgentSpawnTool( - DefaultAgentManager agentManager, TaskRepository taskRepository, int parentSpawnDepth) { + DefaultAgentManager agentManager, + TaskRepository taskRepository, + int parentSpawnDepth, + Supplier userIdSupplier) { this.agentManager = Objects.requireNonNull(agentManager, "agentManager"); this.taskRepository = taskRepository; this.parentSpawnDepth = parentSpawnDepth; + this.userIdSupplier = userIdSupplier != null ? userIdSupplier : () -> null; } @Tool( @@ -84,6 +104,7 @@ public AgentSpawnTool( async (timeout_seconds=0) adds task_id for task_output — task_id is NOT agent_key.\ """) public String agentSpawn( + RuntimeContext runtimeContext, @ToolParam(name = "agent_id", description = "Subagent identifier to instantiate") String agentId, @ToolParam( @@ -123,6 +144,7 @@ public String agentSpawn( Agent agent = agentManager.createAgent(agentId); String key = "agent:" + agentId + ":" + UUID.randomUUID(); String sessionId = "sub-" + UUID.randomUUID(); + String currentUserId = userIdSupplier.get(); SpawnedAgent spawned = new SpawnedAgent(key, agentId, sessionId, canonLabel, agent, nextDepth); @@ -139,6 +161,7 @@ public String agentSpawn( } long timeoutMs = resolveTimeoutMs(timeoutSeconds, DEFAULT_TIMEOUT_SECONDS); + String parentSessionId = runtimeContext != null ? runtimeContext.getSessionId() : null; if (timeoutMs == 0) { String taskId = "task_" + UUID.randomUUID(); @@ -146,11 +169,13 @@ public String agentSpawn( taskRepository.putTask( taskId, agentId, + parentSessionId, () -> { try { Msg reply = agentManager - .invokeAgent(agent, sessionId, capturedTask) + .invokeAgent( + agent, sessionId, currentUserId, capturedTask) .block(); return reply != null ? reply.getTextContent() : ""; } catch (RuntimeException e) { @@ -166,7 +191,7 @@ public String agentSpawn( try { Msg reply = agentManager - .invokeAgent(agent, sessionId, task.trim()) + .invokeAgent(agent, sessionId, currentUserId, task.trim()) .block(Duration.ofMillis(timeoutMs)); String text = reply != null ? reply.getTextContent() : ""; return spawnInfo + "\nstatus: ok\nreply:\n" + text; @@ -187,6 +212,7 @@ agent_key line of agent_spawn output (starts with agent:), or the label \ timeout_seconds=0 returns task_id for task_output.\ """) public String agentSend( + RuntimeContext runtimeContext, @ToolParam( name = "agent_key", description = @@ -242,18 +268,24 @@ public String agentSend( } long timeoutMs = resolveTimeoutMs(timeoutSeconds, DEFAULT_TIMEOUT_SECONDS); + String currentUserId = userIdSupplier.get(); + String parentSessionId = runtimeContext != null ? runtimeContext.getSessionId() : null; if (timeoutMs == 0) { String taskId = "task_" + UUID.randomUUID(); taskRepository.putTask( taskId, spawned.agentId(), + parentSessionId, () -> { try { Msg reply = agentManager .invokeAgent( - spawned.agent(), spawned.sessionId(), message) + spawned.agent(), + spawned.sessionId(), + currentUserId, + message) .block(); return reply != null ? reply.getTextContent() : ""; } catch (RuntimeException e) { @@ -269,7 +301,11 @@ public String agentSend( try { Msg reply = agentManager - .invokeAgent(spawned.agent(), spawned.sessionId(), message.trim()) + .invokeAgent( + spawned.agent(), + spawned.sessionId(), + currentUserId, + message.trim()) .block(Duration.ofMillis(timeoutMs)); String text = reply != null ? reply.getTextContent() : ""; return "agent_key: " + key + "\nstatus: ok\nreply:\n" + text; 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 index 1b725a04e..aee6c7972 100644 --- 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 @@ -15,6 +15,7 @@ */ 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.subagent.task.BackgroundTask; @@ -33,6 +34,10 @@ *

  • {@code task_cancel} — cancel a running task *
  • {@code task_list} — list all tracked tasks with optional status filter * + * + *

    All operations are scoped to the current parent session ID via {@link RuntimeContext}. The + * {@link TaskRepository} handles fallback to workspace-persisted records when no local future + * exists (cross-node or post-restart scenarios). */ public class TaskTool { @@ -49,9 +54,13 @@ public TaskTool(TaskRepository taskRepository) { 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).") + + " agent_send was called with timeout_seconds=0. Prefer block=false to" + + " check status without waiting. Only use block=true (the default) when" + + " you are ready to wait for the result. Do NOT call this immediately" + + " after launching a task — the task status in conversation history is" + + " stale; always call task_output or task_list to get the current state.") public String taskOutput( + RuntimeContext runtimeContext, @ToolParam( name = "task_id", description = @@ -60,7 +69,9 @@ public String taskOutput( String taskId, @ToolParam( name = "block", - description = "Whether to wait for completion (default: true)", + description = + "Whether to wait for completion (default: true). Prefer" + + " false for status checks to avoid blocking.", required = false) Boolean block, @ToolParam( @@ -74,9 +85,12 @@ public String taskOutput( return "Error: task_id is required"; } - BackgroundTask bgTask = taskRepository.getTask(taskId); + String sessionId = runtimeContext != null ? runtimeContext.getSessionId() : null; + BackgroundTask bgTask = taskRepository.getTask(sessionId, taskId); if (bgTask == null) { - return "Error: No background task found with ID: " + taskId; + return "Error: No background task found with ID: " + + taskId + + ". Use task_list() to see all known tasks for this session."; } bgTask.updateLastCheckedAt(); @@ -85,11 +99,24 @@ public String taskOutput( 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"; + // If the task has no local future (cross-node or post-restart), degrade gracefully + // instead of blocking indefinitely on an incomplete synthetic future. + if (bgTask.getTaskStatus() == TaskStatus.PENDING + || bgTask.getTaskStatus() == TaskStatus.RUNNING) { + try { + bgTask.waitForCompletion(timeoutMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return "Error: Wait for task interrupted"; + } + // After waiting, if still not complete it may be running on another node + if (!bgTask.isCompleted()) { + return "task_id: " + + taskId + + "\nstatus: running" + + "\nnote: Task is running (possibly on another node)." + + " Use task_output(block=false) to poll for completion."; + } } } @@ -102,13 +129,15 @@ public String taskOutput( "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( + RuntimeContext runtimeContext, @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); + String sessionId = runtimeContext != null ? runtimeContext.getSessionId() : null; + BackgroundTask bgTask = taskRepository.getTask(sessionId, taskId); if (bgTask == null) { return "Error: No background task found with ID: " + taskId; } @@ -122,17 +151,20 @@ public String taskCancel( + "\nnote: Task already in terminal state, cannot cancel."; } - taskRepository.cancelTask(taskId); + taskRepository.cancelTask(sessionId, 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.") + "List all background tasks for the current session with their current statuses." + + " Reads from durable workspace storage — always accurate even after" + + " conversation compaction or node migration. Optionally filter by status" + + " (running, completed, failed, cancelled). Use this to recover task IDs" + + " and state after compaction.") public String taskList( + RuntimeContext runtimeContext, @ToolParam( name = "status_filter", description = @@ -142,7 +174,8 @@ public String taskList( String statusFilter) { TaskStatus filter = parseStatusFilter(statusFilter); - Collection tasks = taskRepository.listTasks(filter); + String sessionId = runtimeContext != null ? runtimeContext.getSessionId() : null; + Collection tasks = taskRepository.listTasks(sessionId, filter); if (tasks.isEmpty()) { String filterDesc = 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 index 84ce87758..3409ad1de 100644 --- 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 @@ -33,6 +33,7 @@ private WorkspaceConstants() {} public static final String AGENTS_DIR = "agents"; public static final String SESSIONS_DIR = "sessions"; + public static final String TASKS_DIR = "tasks"; /** * Per-agent session store filename under {@code agents/<agentId>/sessions/} @@ -44,6 +45,4 @@ private WorkspaceConstants() {} /** 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 index fb7f78b22..a5689001c 100644 --- 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 @@ -24,25 +24,35 @@ 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 static io.agentscope.harness.agent.workspace.WorkspaceConstants.TASKS_DIR; +import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.node.ObjectNode; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; 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 io.agentscope.harness.agent.subagent.task.TaskRecord; 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.Collection; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.LinkedHashSet; import java.util.List; import java.util.Map; +import java.util.Optional; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; import java.util.stream.Stream; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -50,8 +60,8 @@ /** * 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 + *

    Read path: For every read (AGENTS.md, MEMORY.md, knowledge, 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}. @@ -72,9 +82,10 @@ * ├── skills/<skill-name>/SKILL.md * ├── knowledge/KNOWLEDGE.md * ├── knowledge/* + * ├── subagents/<id>.md (subagent declarations) + * ├── agents/<agentId>/workspace/ (isolated subagent runtime root, auto-created) * ├── agents/<agentId>/sessions/sessions.json - * ├── agents/<agentId>/sessions/<sessionId>.log.jsonl - * └── subagent.yml + * └── agents/<agentId>/sessions/<sessionId>.log.jsonl * */ public class WorkspaceManager { @@ -83,6 +94,18 @@ public class WorkspaceManager { private static final Logger log = LoggerFactory.getLogger(WorkspaceManager.class); private static final ObjectMapper SESSION_STORE_JSON = new ObjectMapper(); + private static final ObjectMapper TASK_RECORD_JSON = + new ObjectMapper() + .registerModule(new JavaTimeModule()) + .disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + private static final TypeReference> TASK_MAP_TYPE = + new TypeReference<>() {}; + + /** + * Per-path locks for task record files to prevent concurrent read-modify-write races. + * Keyed by workspace-relative path (e.g. {@code agents/X/tasks/Y.json}). + */ + private final Map taskFileLocks = new ConcurrentHashMap<>(); private final Path workspace; private final AbstractFilesystem filesystem; @@ -158,11 +181,6 @@ public String readManagedWorkspaceFileUtf8(String relativePath) { 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); } @@ -294,6 +312,93 @@ public void updateSessionIndex(String agentId, String sessionId, String summary) } } + // ==================== Task record methods ==================== + + /** + * Upserts a {@link TaskRecord} in {@code agents//tasks/.json}. + * + *

    Reads the existing map, merges or inserts the record keyed by {@code taskId}, then + * writes the updated map back. Acquires a per-file {@link ReentrantLock} to prevent + * concurrent read-modify-write races when multiple tasks share the same session file. + */ + public void writeTaskRecord(String agentId, String sessionId, TaskRecord record) { + if (agentId == null + || agentId.isBlank() + || sessionId == null + || sessionId.isBlank() + || record == null + || record.getTaskId() == null) { + return; + } + String rel = taskRecordPath(agentId, sessionId); + ReentrantLock lock = taskFileLocks.computeIfAbsent(rel, k -> new ReentrantLock()); + lock.lock(); + try { + Map map = readTaskMap(rel); + record.touch(); + map.put(record.getTaskId(), record); + persistTaskMap(rel, map); + } finally { + lock.unlock(); + } + } + + /** + * Reads a single {@link TaskRecord} by task ID, or {@link Optional#empty()} if not found. + */ + public Optional readTaskRecord(String agentId, String sessionId, String taskId) { + if (agentId == null + || agentId.isBlank() + || sessionId == null + || sessionId.isBlank() + || taskId == null + || taskId.isBlank()) { + return Optional.empty(); + } + String rel = taskRecordPath(agentId, sessionId); + Map map = readTaskMap(rel); + return Optional.ofNullable(map.get(taskId)); + } + + /** + * Returns all {@link TaskRecord}s for the given agent and session, in insertion order. + */ + public Collection listTaskRecords(String agentId, String sessionId) { + if (agentId == null || agentId.isBlank() || sessionId == null || sessionId.isBlank()) { + return Collections.emptyList(); + } + String rel = taskRecordPath(agentId, sessionId); + return List.copyOf(readTaskMap(rel).values()); + } + + private String taskRecordPath(String agentId, String sessionId) { + return AGENTS_DIR + "/" + agentId + "/" + TASKS_DIR + "/" + sessionId + ".json"; + } + + private Map readTaskMap(String rel) { + String json = readWritableWorkspaceRelativeUtf8(rel); + if (json == null || json.isBlank()) { + return new LinkedHashMap<>(); + } + try { + Map map = TASK_RECORD_JSON.readValue(json, TASK_MAP_TYPE); + return map != null ? new LinkedHashMap<>(map) : new LinkedHashMap<>(); + } catch (IOException e) { + log.warn("Corrupt task record store {}, reinitializing: {}", rel, e.getMessage()); + return new LinkedHashMap<>(); + } + } + + private void persistTaskMap(String rel, Map map) { + try { + String serialized = + TASK_RECORD_JSON.writerWithDefaultPrettyPrinter().writeValueAsString(map); + writeUtf8WorkspaceRelative(rel, serialized); + } catch (IOException e) { + log.warn("Failed to write task record store {}: {}", rel, e.getMessage()); + } + } + private ObjectNode parseSessionStoreOrEmpty(String json) { if (json == null || json.isBlank()) { return SESSION_STORE_JSON.createObjectNode(); 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 index dc4a3585a..bd589537e 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentIntegrationExampleTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentIntegrationExampleTest.java @@ -96,28 +96,25 @@ void example_fullWorkspace_singleTurn_seesSessionSubagentsAndWorkspaceContext() Path subagentsDir = workspace.resolve("subagents"); Files.createDirectories(subagentsDir); + // Filename (without .md) is the subagent name — no 'name:' field in front matter Files.writeString( - subagentsDir.resolve("helper.md"), + subagentsDir.resolve(helperSubId + ".md"), """ --- - name: %s description: First markdown-defined helper for integration example --- Reply with YAML_OK only. - """ - .formatted(helperSubId)); + """); Files.writeString( - subagentsDir.resolve("reviewer.md"), + subagentsDir.resolve(reviewerSubId + ".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 = @@ -183,19 +180,18 @@ void example_subagentFactory_markdownSpec_runsChildHarnessAgent() throws Excepti Files.createDirectories(workspace); Files.writeString(workspace.resolve(WorkspaceConstants.AGENTS_MD), "# root\n"); + // Filename (without .md) is the subagent name — no 'name:' field in front matter String childId = "integration-child-spawn"; Path subagentsDir = workspace.resolve("subagents"); Files.createDirectories(subagentsDir); Files.writeString( - subagentsDir.resolve("child.md"), + subagentsDir.resolve(childId + ".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"); 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 index 6c357e81d..4c12c3778 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentModelStringTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentModelStringTest.java @@ -28,7 +28,7 @@ import io.agentscope.core.model.ModelRegistry; import io.agentscope.harness.agent.filesystem.local.LocalFilesystem; import io.agentscope.harness.agent.hook.SubagentsHook.SubagentEntry; -import io.agentscope.harness.agent.subagent.SubagentSpec; +import io.agentscope.harness.agent.subagent.SubagentDeclaration; import java.nio.file.Files; import java.nio.file.Path; import java.util.List; @@ -84,14 +84,18 @@ void builder_modelString_unknownId_throws() { } @Test - void subagentSpec_model_resolvedByDefaultResolver() { + void subagentDeclaration_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"); + SubagentDeclaration decl = + SubagentDeclaration.builder() + .name("sa") + .description("subagent") + .inlineAgentsBody("You are a test subagent.") + .model("reg-sub") + .build(); List entries = HarnessAgent.builder() @@ -99,7 +103,7 @@ void subagentSpec_model_resolvedByDefaultResolver() { .model(main) .workspace(workspace) .abstractFilesystem(new LocalFilesystem(workspace)) - .subagent(spec) + .subagent(decl) .buildSubagentEntries(workspace); SubagentEntry entry = 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 index b1a35b947..20dd74376 100644 --- a/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentTest.java +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/HarnessAgentTest.java @@ -15,7 +15,9 @@ */ package io.agentscope.harness.agent; +import static org.junit.jupiter.api.Assertions.assertEquals; 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 static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyList; @@ -35,7 +37,11 @@ import io.agentscope.harness.agent.filesystem.local.LocalFilesystem; import io.agentscope.harness.agent.filesystem.spec.RemoteFilesystemSpec; import io.agentscope.harness.agent.hook.SubagentsHook.SubagentEntry; +import io.agentscope.harness.agent.memory.compaction.CompactionConfig; import io.agentscope.harness.agent.store.InMemoryStore; +import io.agentscope.harness.agent.subagent.AgentSpecLoader; +import io.agentscope.harness.agent.subagent.SubagentDeclaration; +import io.agentscope.harness.agent.subagent.WorkspaceMode; import io.agentscope.harness.agent.workspace.WorkspaceConstants; import java.nio.file.Files; import java.nio.file.Path; @@ -225,19 +231,18 @@ void workspaceAgentsMd_injectedIntoMessagesSeenByModel() throws Exception { void subagentMarkdown_registersIdsAndSubagentTools() throws Exception { Files.createDirectories(workspace); Files.writeString(workspace.resolve(WorkspaceConstants.AGENTS_MD), "# workspace\n"); - String specId = "markdown-subagent-id-77"; + // The filename (without .md) becomes the subagent name + String specId = "from-md"; Path subagents = workspace.resolve("subagents"); Files.createDirectories(subagents); Files.writeString( - subagents.resolve("from-md.md"), + subagents.resolve(specId + ".md"), """ --- - name: %s description: From subagents/*.md for tests --- You only reply OK. - """ - .formatted(specId)); + """); Model model = stubModel("done"); HarnessAgent agent = @@ -273,31 +278,30 @@ void subagentMarkdown_registersIdsAndSubagentTools() throws Exception { combined.contains("## Subagents"), "subagent hook should inject Subagents section"); assertTrue( combined.contains("`" + specId + "`"), - "Markdown subagent id should appear in prompt"); + "Markdown subagent id (from filename) should appear in prompt"); assertTrue( combined.contains("general-purpose"), "built-in general-purpose entry should be listed"); } @Test - void subagentsDir_loadsMarkdownSpecs() throws Exception { + void subagentsDir_loadsMarkdownDeclarations() 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"; + // Name is derived from the filename, not the front matter + String expectedName = "helper"; Files.writeString( - subagents.resolve("helper.md"), + subagents.resolve(expectedName + ".md"), """ --- - name: %s description: Loaded from subagents/*.md maxIters: 3 --- You are a test subagent from markdown. - """ - .formatted(mdId)); + """); List entries = HarnessAgent.builder() @@ -308,7 +312,8 @@ void subagentsDir_loadsMarkdownSpecs() throws Exception { 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"); + names.contains(expectedName), + "subagents/*.md declaration should use filename as name"); } @Test @@ -343,6 +348,377 @@ private static String joinAllText(List msgs) { return msgs.stream().map(Msg::getTextContent).collect(Collectors.joining("\n")); } + // ========================================================================= + // Decision table — five workspace/sysPrompt resolution paths + // ========================================================================= + + /** Row 1: isolated + workspace.path → runtime root = workspacePath. */ + @Test + void decisionTable_row1_isolatedWithPath_runtimeRootIsWorkspacePath() throws Exception { + Files.createDirectories(workspace); + Path defWorkspace = workspace.resolve("defs/reviewer"); + Files.createDirectories(defWorkspace); + Files.writeString(defWorkspace.resolve("AGENTS.md"), "reviewer-agents-md"); + + SubagentDeclaration decl = + SubagentDeclaration.builder() + .name("reviewer") + .description("code reviewer") + .workspace(defWorkspace) + .workspaceMode(WorkspaceMode.ISOLATED) + .build(); + + List entries = + HarnessAgent.builder() + .model(stubModel("ok")) + .workspace(workspace) + .subagent(decl) + .buildSubagentEntries(workspace); + + SubagentEntry entry = + entries.stream().filter(e -> "reviewer".equals(e.name())).findFirst().orElseThrow(); + HarnessAgent child = (HarnessAgent) entry.factory().create(); + + assertEquals( + defWorkspace.normalize(), + child.getWorkspaceManager().getWorkspace().normalize(), + "isolated+path: runtime workspace must be the definition workspace"); + } + + /** Row 2: isolated + no path → runtime root is auto-created agents/<name>/workspace. */ + @Test + void decisionTable_row2_isolatedNoPath_autoCreatesAgentSubdir() throws Exception { + Files.createDirectories(workspace); + + SubagentDeclaration decl = + SubagentDeclaration.builder() + .name("isolated-auto") + .description("auto-isolated subagent") + .workspaceMode(WorkspaceMode.ISOLATED) + .inlineAgentsBody("You are an isolated subagent.") + .build(); + + List entries = + HarnessAgent.builder() + .model(stubModel("ok")) + .workspace(workspace) + .subagent(decl) + .buildSubagentEntries(workspace); + + SubagentEntry entry = + entries.stream() + .filter(e -> "isolated-auto".equals(e.name())) + .findFirst() + .orElseThrow(); + HarnessAgent child = (HarnessAgent) entry.factory().create(); + + Path expected = workspace.resolve("agents/isolated-auto/workspace").normalize(); + assertEquals( + expected, + child.getWorkspaceManager().getWorkspace().normalize(), + "isolated+no-path: runtime workspace must be auto agents//workspace"); + assertTrue( + Files.isDirectory(expected), + "isolated auto workspace directory should be created on the filesystem"); + } + + /** Row 3: shared + workspace.path → runtime root = mainWorkspace. */ + @Test + void decisionTable_row3_sharedWithPath_runtimeRootIsMainWorkspace() throws Exception { + Files.createDirectories(workspace); + Path defWorkspace = workspace.resolve("defs/shared-def"); + Files.createDirectories(defWorkspace); + Files.writeString(defWorkspace.resolve("AGENTS.md"), "shared-def-agents"); + + SubagentDeclaration decl = + SubagentDeclaration.builder() + .name("shared-ext") + .description("shared subagent with def workspace") + .workspace(defWorkspace) + .workspaceMode(WorkspaceMode.SHARED) + .build(); + + List entries = + HarnessAgent.builder() + .model(stubModel("ok")) + .workspace(workspace) + .subagent(decl) + .buildSubagentEntries(workspace); + + SubagentEntry entry = + entries.stream() + .filter(e -> "shared-ext".equals(e.name())) + .findFirst() + .orElseThrow(); + HarnessAgent child = (HarnessAgent) entry.factory().create(); + + assertEquals( + workspace.normalize(), + child.getWorkspaceManager().getWorkspace().normalize(), + "shared+path: runtime workspace must be mainWorkspace, not the def path"); + } + + /** Row 4: shared + no path → runtime root = mainWorkspace. */ + @Test + void decisionTable_row4_sharedNoPath_runtimeRootIsMainWorkspace() throws Exception { + Files.createDirectories(workspace); + + SubagentDeclaration decl = + SubagentDeclaration.builder() + .name("shared-inline") + .description("inline shared subagent") + .workspaceMode(WorkspaceMode.SHARED) + .inlineAgentsBody("You share the main workspace.") + .build(); + + List entries = + HarnessAgent.builder() + .model(stubModel("ok")) + .workspace(workspace) + .subagent(decl) + .buildSubagentEntries(workspace); + + SubagentEntry entry = + entries.stream() + .filter(e -> "shared-inline".equals(e.name())) + .findFirst() + .orElseThrow(); + HarnessAgent child = (HarnessAgent) entry.factory().create(); + + assertEquals( + workspace.normalize(), + child.getWorkspaceManager().getWorkspace().normalize(), + "shared+no-path: runtime workspace must be mainWorkspace"); + } + + /** Row 5 (built-in general-purpose): runtime root = mainWorkspace. */ + @Test + void decisionTable_row5_generalPurpose_runtimeRootIsMainWorkspace() throws Exception { + Files.createDirectories(workspace); + + List entries = + HarnessAgent.builder() + .model(stubModel("ok")) + .workspace(workspace) + .buildSubagentEntries(workspace); + + SubagentEntry gp = + entries.stream() + .filter(e -> "general-purpose".equals(e.name())) + .findFirst() + .orElseThrow(); + HarnessAgent child = (HarnessAgent) gp.factory().create(); + + assertEquals( + workspace.normalize(), + child.getWorkspaceManager().getWorkspace().normalize(), + "general-purpose must share mainWorkspace"); + } + + // ========================================================================= + // general-purpose mirroring + // ========================================================================= + + @Test + void generalPurpose_mirrorDisableFilesystemTools() throws Exception { + Files.createDirectories(workspace); + List entries = + HarnessAgent.builder() + .model(stubModel("ok")) + .workspace(workspace) + .disableFilesystemTools() + .buildSubagentEntries(workspace); + + HarnessAgent child = + (HarnessAgent) + entries.stream() + .filter(e -> "general-purpose".equals(e.name())) + .findFirst() + .orElseThrow() + .factory() + .create(); + List toolNames = + child.getDelegate().getToolkit().getToolSchemas().stream() + .map(ToolSchema::getName) + .toList(); + assertFalse(toolNames.contains("read_file"), "disableFilesystemTools should be mirrored"); + } + + @Test + void generalPurpose_mirrorCompactionConfig() throws Exception { + Files.createDirectories(workspace); + CompactionConfig cfg = CompactionConfig.builder().triggerMessages(5).build(); + List entries = + HarnessAgent.builder() + .model(stubModel("ok")) + .workspace(workspace) + .compaction(cfg) + .buildSubagentEntries(workspace); + + HarnessAgent child = + (HarnessAgent) + entries.stream() + .filter(e -> "general-purpose".equals(e.name())) + .findFirst() + .orElseThrow() + .factory() + .create(); + assertNotNull(child.getCompactionHook(), "CompactionHook should be mirrored to GP child"); + } + + // ========================================================================= + // Multiple declarations → same definition workspace + // ========================================================================= + + @Test + void multipleDeclarations_sameDefinitionWorkspace_bothResolveSamePath() throws Exception { + Files.createDirectories(workspace); + Path defWorkspace = workspace.resolve("defs/shared-def"); + Files.createDirectories(defWorkspace); + Files.writeString(defWorkspace.resolve("AGENTS.md"), "shared definition"); + + SubagentDeclaration decl1 = + SubagentDeclaration.builder() + .name("agent-a") + .description("first alias") + .workspace(defWorkspace) + .workspaceMode(WorkspaceMode.ISOLATED) + .build(); + SubagentDeclaration decl2 = + SubagentDeclaration.builder() + .name("agent-b") + .description("second alias") + .workspace(defWorkspace) + .workspaceMode(WorkspaceMode.ISOLATED) + .build(); + + List entries = + HarnessAgent.builder() + .model(stubModel("ok")) + .workspace(workspace) + .subagent(decl1) + .subagent(decl2) + .buildSubagentEntries(workspace); + + HarnessAgent childA = + (HarnessAgent) + entries.stream() + .filter(e -> "agent-a".equals(e.name())) + .findFirst() + .orElseThrow() + .factory() + .create(); + HarnessAgent childB = + (HarnessAgent) + entries.stream() + .filter(e -> "agent-b".equals(e.name())) + .findFirst() + .orElseThrow() + .factory() + .create(); + + assertEquals( + defWorkspace.normalize(), childA.getWorkspaceManager().getWorkspace().normalize()); + assertEquals( + defWorkspace.normalize(), + childB.getWorkspaceManager().getWorkspace().normalize(), + "Both declarations point to the same definition workspace"); + } + + // ========================================================================= + // Tools allowlist + // ========================================================================= + + @Test + void toolsAllowlist_narrowsChildToolkit() throws Exception { + Files.createDirectories(workspace); + + SubagentDeclaration decl = + SubagentDeclaration.builder() + .name("narrow") + .description("narrowed toolkit") + .inlineAgentsBody("Only read files.") + .tools(List.of("read_file")) + .build(); + + List entries = + HarnessAgent.builder() + .model(stubModel("ok")) + .workspace(workspace) + .subagent(decl) + .buildSubagentEntries(workspace); + + HarnessAgent child = + (HarnessAgent) + entries.stream() + .filter(e -> "narrow".equals(e.name())) + .findFirst() + .orElseThrow() + .factory() + .create(); + List toolNames = + child.getDelegate().getToolkit().getToolSchemas().stream() + .map(ToolSchema::getName) + .toList(); + assertTrue(toolNames.contains("read_file"), "allowed tool should remain"); + assertFalse(toolNames.contains("list_files"), "non-allowlisted tool should be removed"); + assertFalse(toolNames.contains("memory_search"), "non-allowlisted tool should be removed"); + } + + // ========================================================================= + // AgentSpecLoader — markdown declaration parsing + // ========================================================================= + + @Test + void agentSpecLoader_markdownDeclaration_isolatedWithWorkspacePath() throws Exception { + Files.createDirectories(workspace); + Path defPath = workspace.resolve("defs/myagent"); + String markdown = + """ + --- + description: My agent description + workspace: + mode: isolated + path: defs/myagent + model: test-model + maxIters: 7 + tools: [read_file, grep_files] + --- + """; + SubagentDeclaration decl = AgentSpecLoader.parse(markdown, "my-agent", workspace); + assertNotNull(decl); + assertEquals("my-agent", decl.getName()); + assertEquals("My agent description", decl.getDescription()); + assertEquals(WorkspaceMode.ISOLATED, decl.getWorkspaceMode()); + assertEquals(defPath.normalize(), decl.getWorkspacePath().normalize()); + assertEquals("test-model", decl.getModel()); + assertEquals(7, decl.getMaxIters()); + assertEquals(List.of("read_file", "grep_files"), decl.getTools()); + } + + @Test + void agentSpecLoader_markdownDeclaration_sharedMode_noPath_inlineBody() throws Exception { + Files.createDirectories(workspace); + String markdown = + """ + --- + description: Inline shared agent + workspace: + mode: shared + --- + + You are the inline sysPrompt. + """; + SubagentDeclaration decl = AgentSpecLoader.parse(markdown, "inline-shared", null); + assertNotNull(decl); + assertEquals("inline-shared", decl.getName()); + assertEquals(WorkspaceMode.SHARED, decl.getWorkspaceMode()); + assertFalse(decl.hasDefinitionWorkspace()); + assertTrue( + decl.getInlineAgentsBody().contains("inline sysPrompt"), + "body should be inline agents body when no workspace.path"); + } + private static Model stubModel(String assistantText) { Model model = mock(Model.class); when(model.getModelName()).thenReturn("stub-model"); 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 index f97233394..6e7bcb621 100644 --- 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 @@ -29,6 +29,7 @@ import io.agentscope.harness.agent.IsolationScope; import io.agentscope.harness.agent.sandbox.snapshot.SandboxSnapshotSpec; import java.util.Optional; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -158,6 +159,28 @@ void userScope_withUserId_loadsFromStore() throws Exception { assertSame(resumedSandbox, result.getSandbox()); } + @Test + void userScope_withUserId_acquiresExecutionGuardWithUserKey() throws Exception { + AtomicReference capturedKey = new AtomicReference<>(); + SandboxExecutionGuard guard = + key -> { + capturedKey.set(key); + return SandboxLease.noop(); + }; + manager = new SandboxManager(client, stateStore, AGENT_ID, guard); + when(stateStore.load(any())).thenReturn(Optional.empty()); + when(client.create(any(), any(), any())).thenReturn(freshSandbox); + + RuntimeContext rtx = RuntimeContext.builder().userId("user-42").build(); + SandboxContext sCtx = SandboxContext.builder().isolationScope(IsolationScope.USER).build(); + + SandboxAcquireResult result = manager.acquire(sCtx, rtx); + + assertSame(freshSandbox, result.getSandbox()); + assertEquals(IsolationScope.USER, capturedKey.get().getScope()); + assertEquals("user-42", capturedKey.get().getValue()); + } + @Test void userScope_missingUserId_createsFreshSession() throws Exception { when(client.create(any(), any(), any())).thenReturn(freshSandbox); diff --git a/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/task/WorkspaceTaskRepositoryTest.java b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/task/WorkspaceTaskRepositoryTest.java new file mode 100644 index 000000000..addd640ab --- /dev/null +++ b/agentscope-harness/src/test/java/io/agentscope/harness/agent/subagent/task/WorkspaceTaskRepositoryTest.java @@ -0,0 +1,430 @@ +/* + * 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 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.agent.RuntimeContext; +import io.agentscope.harness.agent.filesystem.CompositeFilesystem; +import io.agentscope.harness.agent.filesystem.spec.RemoteFilesystemSpec; +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.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +/** + * Tests for {@link WorkspaceTaskRepository}: + * + *

      + *
    • Workspace write on task creation and completion + *
    • Cross-node fallback: no local future → read terminal state from workspace + *
    • Session-scope isolation: different sessionIds are independent + *
    • Cancel coordination: cancelRequested flag persisted to workspace + *
    • Compaction simulation: task_list reads from workspace even after localTasks cleared + *
    • Terminal status never overridden by RUNNING overlay + *
    • Mode 1: RemoteFilesystemSpec routes include tasks path + *
    + */ +class WorkspaceTaskRepositoryTest { + + @TempDir Path tempDir; + + private WorkspaceManager workspaceManager; + private WorkspaceTaskRepository repo; + + @BeforeEach + void setUp() { + workspaceManager = new WorkspaceManager(tempDir); + repo = new WorkspaceTaskRepository(workspaceManager, "test-agent"); + } + + // ------------------------------------------------------------------ + // Helpers + // ------------------------------------------------------------------ + + /** Polls until the condition is true or 5 seconds elapses. */ + private static void awaitCondition(ConditionSupplier condition) throws Exception { + long deadline = System.currentTimeMillis() + 5_000; + while (!condition.get()) { + if (System.currentTimeMillis() >= deadline) { + throw new AssertionError("Condition not met within 5 seconds"); + } + Thread.sleep(50); + } + } + + @FunctionalInterface + interface ConditionSupplier { + boolean get() throws Exception; + } + + // ------------------------------------------------------------------ + // Basic workspace write + // ------------------------------------------------------------------ + + @Test + @DisplayName("putTask writes TaskRecord to workspace with COMPLETED status on success") + void putTask_writesRecordToWorkspace() throws Exception { + String session = "sess-1"; + String taskId = "task-write-test"; + AtomicBoolean executed = new AtomicBoolean(false); + + repo.putTask( + taskId, + "sub-agent-x", + session, + () -> { + executed.set(true); + return "done"; + }); + + awaitCondition( + () -> { + BackgroundTask t = repo.getTask(session, taskId); + return t != null && t.getTaskStatus().isTerminal(); + }); + + Optional record = + workspaceManager.readTaskRecord("test-agent", session, taskId); + assertTrue(record.isPresent()); + assertEquals(TaskStatus.COMPLETED, record.get().getStatus()); + assertEquals("done", record.get().getResult()); + assertTrue(executed.get()); + } + + @Test + @DisplayName("putTask writes FAILED status when task throws") + void putTask_writesFailedOnException() throws Exception { + String session = "sess-fail"; + String taskId = "task-fail-test"; + + repo.putTask( + taskId, + "sub-agent-fail", + session, + () -> { + throw new RuntimeException("intentional failure"); + }); + + awaitCondition( + () -> { + BackgroundTask t = repo.getTask(session, taskId); + return t != null && t.getTaskStatus().isTerminal(); + }); + + awaitCondition( + () -> { + Optional r = + workspaceManager.readTaskRecord("test-agent", session, taskId); + return r.isPresent() && r.get().getStatus().isTerminal(); + }); + + Optional record = + workspaceManager.readTaskRecord("test-agent", session, taskId); + assertTrue(record.isPresent()); + assertEquals(TaskStatus.FAILED, record.get().getStatus()); + assertTrue(record.get().getErrorMessage().contains("intentional failure")); + } + + // ------------------------------------------------------------------ + // Cross-node fallback: no local future + // ------------------------------------------------------------------ + + @Test + @DisplayName("getTask falls back to workspace when localTasks cleared (cross-node simulation)") + void getTask_fallsBackToWorkspaceAfterLocalTasksCleared() throws Exception { + String session = "sess-cross"; + String taskId = "task-cross-node"; + + repo.putTask(taskId, "agent-y", session, () -> "cross-node result"); + + awaitCondition( + () -> { + Optional r = + workspaceManager.readTaskRecord("test-agent", session, taskId); + return r.isPresent() && r.get().getStatus().isTerminal(); + }); + + // Simulate cross-node scenario: clear in-memory tasks + repo.clear(); + + BackgroundTask synthetic = repo.getTask(session, taskId); + assertNotNull(synthetic); + assertEquals(TaskStatus.COMPLETED, synthetic.getTaskStatus()); + assertEquals("cross-node result", synthetic.getResult()); + } + + @Test + @DisplayName( + "getTask returns null for unknown task on cross-node node without workspace record") + void getTask_returnsNullWhenNothingFound() { + assertNull(repo.getTask("no-session", "no-task")); + } + + // ------------------------------------------------------------------ + // Session-scope isolation + // ------------------------------------------------------------------ + + @Test + @DisplayName("listTasks isolates tasks by sessionId") + void listTasks_sessionIsolation() throws Exception { + String sessionA = "sess-a"; + String sessionB = "sess-b"; + + repo.putTask("task-a1", "agent-a", sessionA, () -> "result-a"); + repo.putTask("task-b1", "agent-b", sessionB, () -> "result-b"); + + awaitCondition( + () -> { + BackgroundTask a = repo.getTask(sessionA, "task-a1"); + BackgroundTask b = repo.getTask(sessionB, "task-b1"); + return a != null + && a.getTaskStatus().isTerminal() + && b != null + && b.getTaskStatus().isTerminal(); + }); + + Collection tasksA = repo.listTasks(sessionA, null); + Collection tasksB = repo.listTasks(sessionB, null); + + assertEquals(1, tasksA.size()); + assertEquals("task-a1", tasksA.iterator().next().getTaskId()); + + assertEquals(1, tasksB.size()); + assertEquals("task-b1", tasksB.iterator().next().getTaskId()); + } + + // ------------------------------------------------------------------ + // Cancel coordination + // ------------------------------------------------------------------ + + @Test + @DisplayName("cancelTask writes cancelRequested=true to workspace and marks CANCELLED") + void cancelTask_writesCancelRequestedToWorkspace() throws Exception { + String session = "sess-cancel"; + String taskId = "task-cancel"; + + // Use latches so task is confirmed RUNNING before we cancel + CountDownLatch taskRunning = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + repo.putTask( + taskId, + "agent-slow", + session, + () -> { + taskRunning.countDown(); + try { + release.await(5, java.util.concurrent.TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + return "slow result"; + }); + + // Wait until task is confirmed RUNNING so workspace has a RUNNING record + taskRunning.await(5, java.util.concurrent.TimeUnit.SECONDS); + awaitCondition( + () -> { + Optional r = + workspaceManager.readTaskRecord("test-agent", session, taskId); + return r.isPresent() && r.get().getStatus() == TaskStatus.RUNNING; + }); + + boolean cancelled = repo.cancelTask(session, taskId); + assertTrue(cancelled); + + // Read workspace before releasing the worker: once the latch opens, the async path + // may persist COMPLETED and would race this assertion under full-suite load. + Optional record = + workspaceManager.readTaskRecord("test-agent", session, taskId); + assertTrue(record.isPresent()); + assertTrue(record.get().isCancelRequested()); + assertEquals(TaskStatus.CANCELLED, record.get().getStatus()); + + release.countDown(); + } + + // ------------------------------------------------------------------ + // Compaction simulation: task_list from workspace after clear + // ------------------------------------------------------------------ + + @Test + @DisplayName("listTasks reads from workspace after localTasks cleared (compaction simulation)") + void listTasks_readsFromWorkspaceAfterCompaction() throws Exception { + String session = "sess-compact"; + + repo.putTask("task-c1", "agent-z", session, () -> "result-c1"); + repo.putTask("task-c2", "agent-z", session, () -> "result-c2"); + + awaitCondition( + () -> { + Optional r1 = + workspaceManager.readTaskRecord("test-agent", session, "task-c1"); + Optional r2 = + workspaceManager.readTaskRecord("test-agent", session, "task-c2"); + return r1.map(r -> r.getStatus().isTerminal()).orElse(false) + && r2.map(r -> r.getStatus().isTerminal()).orElse(false); + }); + + repo.clear(); + + Collection tasks = repo.listTasks(session, null); + assertEquals(2, tasks.size()); + assertTrue(tasks.stream().anyMatch(t -> t.getTaskId().equals("task-c1"))); + assertTrue(tasks.stream().anyMatch(t -> t.getTaskId().equals("task-c2"))); + } + + // ------------------------------------------------------------------ + // Terminal status not overridden + // ------------------------------------------------------------------ + + @Test + @DisplayName("listTasks does not override COMPLETED workspace status with RUNNING") + void listTasks_terminalStatusNotOverridden() throws Exception { + String session = "sess-term"; + String taskId = "task-term"; + + repo.putTask(taskId, "agent-t", session, () -> "terminal result"); + + awaitCondition( + () -> { + BackgroundTask t = repo.getTask(session, taskId); + return t != null && t.getTaskStatus().isTerminal(); + }); + + Collection tasks = repo.listTasks(session, null); + assertEquals(1, tasks.size()); + assertEquals(TaskStatus.COMPLETED, tasks.iterator().next().getTaskStatus()); + } + + // ------------------------------------------------------------------ + // Status filter + // ------------------------------------------------------------------ + + @Test + @DisplayName("listTasks with filter returns only matching status tasks (cross-node)") + void listTasks_withFilter_crossNode() throws Exception { + // Use separate sessions to avoid concurrent writes to the same file + String sessionOk = "sess-filter-ok"; + String sessionErr = "sess-filter-err"; + + repo.putTask("task-ok", "agent-f", sessionOk, () -> "ok"); + repo.putTask( + "task-err", + "agent-f", + sessionErr, + () -> { + throw new RuntimeException("error"); + }); + + awaitCondition( + () -> { + Optional r1 = + workspaceManager.readTaskRecord("test-agent", sessionOk, "task-ok"); + return r1.map(r -> r.getStatus().isTerminal()).orElse(false); + }); + awaitCondition( + () -> { + Optional r2 = + workspaceManager.readTaskRecord("test-agent", sessionErr, "task-err"); + return r2.map(r -> r.getStatus().isTerminal()).orElse(false); + }); + + repo.clear(); + + Collection completed = repo.listTasks(sessionOk, TaskStatus.COMPLETED); + assertEquals(1, completed.size()); + assertEquals("task-ok", completed.iterator().next().getTaskId()); + + Collection failed = repo.listTasks(sessionErr, TaskStatus.FAILED); + assertEquals(1, failed.size()); + assertEquals("task-err", failed.iterator().next().getTaskId()); + } + + // ------------------------------------------------------------------ + // WorkspaceManager task record round-trip + // ------------------------------------------------------------------ + + @Test + @DisplayName("WorkspaceManager writeTaskRecord / readTaskRecord / listTaskRecords round-trip") + void workspaceManager_taskRecordRoundTrip() throws Exception { + TaskRecord r1 = new TaskRecord("t1", "agent-a", "parent", "sess-rt", null); + r1.setStatus(TaskStatus.RUNNING); + TaskRecord r2 = new TaskRecord("t2", "agent-b", "parent", "sess-rt", null); + r2.setStatus(TaskStatus.COMPLETED); + r2.setResult("my result"); + + workspaceManager.writeTaskRecord("parent", "sess-rt", r1); + workspaceManager.writeTaskRecord("parent", "sess-rt", r2); + + Optional read1 = workspaceManager.readTaskRecord("parent", "sess-rt", "t1"); + assertTrue(read1.isPresent()); + assertEquals(TaskStatus.RUNNING, read1.get().getStatus()); + + Optional read2 = workspaceManager.readTaskRecord("parent", "sess-rt", "t2"); + assertTrue(read2.isPresent()); + assertEquals("my result", read2.get().getResult()); + + Collection all = workspaceManager.listTaskRecords("parent", "sess-rt"); + assertEquals(2, all.size()); + + Path file = tempDir.resolve("agents/parent/tasks/sess-rt.json"); + assertTrue(Files.exists(file), "Task record JSON file should exist on disk"); + } + + // ------------------------------------------------------------------ + // RemoteFilesystemSpec tasks route + // ------------------------------------------------------------------ + + @Test + @DisplayName("RemoteFilesystemSpec includes agents//tasks/ as shared route") + void remoteFilesystemSpec_includesTasksRoute() throws Exception { + InMemoryStore store = new InMemoryStore(); + RemoteFilesystemSpec spec = new RemoteFilesystemSpec(store); + + var fs = spec.toFilesystem(tempDir, "my-agent", () -> null, () -> null); + + assertTrue( + fs instanceof CompositeFilesystem, + "Expected CompositeFilesystem for RemoteFilesystemSpec"); + + // Write to the tasks path — should be routed to RemoteFilesystem (InMemoryStore) + String taskPath = "agents/my-agent/tasks/sess-test.json"; + fs.uploadFiles( + RuntimeContext.empty(), List.of(Map.entry(taskPath, "{\"test\":true}".getBytes()))); + + // Read back via the filesystem — should succeed and return the content + var readResult = fs.read(RuntimeContext.empty(), taskPath, 0, 0); + assertTrue( + readResult.isSuccess(), "Task record read should succeed via CompositeFilesystem"); + assertTrue( + readResult.fileData().content().contains("test"), + "Task record content should be readable"); + } +} diff --git a/docs/_config.yml b/docs/_config.yml index 53f47a335..4a88612ad 100644 --- a/docs/_config.yml +++ b/docs/_config.yml @@ -50,6 +50,9 @@ sphinx: - sphinx.ext.autosummary - sphinx_llms_txt - sphinx_sitemap + # Patches root index.html redirect stub so docs_site_notice appears on "/". + local_extensions: + root_index_notice: ./_sphinx_extensions config: # Sitemap Configuration html_baseurl: "https://java.agentscope.io/" @@ -103,15 +106,141 @@ sphinx: templates_path: ["./_templates"] html_static_path: - "_static" + html_css_files: + - custom.css use_multitoc_numbering: false html_js_files: + - tabs.js - language.js + - notice-bar.js + html_context: + docs_top_tabs: + # Order: Home → Harness → Agent → Integration → Blogs → Community + - id: home + label_en: Home + label_zh: 首页 + href_en: en/intro + href_zh: zh/intro + mirror_paths: true + prefixes_en: + - en/intro + prefixes_zh: + - zh/intro + sections_en: [] + sections_zh: [] + languages: + - en + - zh + - id: harness + label_en: Harness + label_zh: Harness + href_en: en/harness/overview + href_zh: zh/harness/overview + mirror_paths: false + prefixes_en: + - en/harness/ + prefixes_zh: + - zh/harness/ + sections_en: + - Harness + sections_zh: + - Harness + languages: + - en + - zh + - id: agent + label_en: Agent + label_zh: Agent + href_en: en/quickstart/installation + href_zh: zh/quickstart/installation + mirror_paths: true + prefixes_en: + - en/quickstart/ + - en/task/ + - en/multi-agent/ + prefixes_zh: + - zh/quickstart/ + - zh/task/ + - zh/multi-agent/ + sections_en: + - Quick Start + - Features + - Multi Agent + sections_zh: + - 快速开始 + - 功能指南 + - 多智能体 + languages: + - en + - zh + - id: integration + label_en: Integration + label_zh: 集成 + href_en: en/integration/overview + href_zh: zh/integration/overview + mirror_paths: true + prefixes_en: + - en/integration/ + prefixes_zh: + - zh/integration/ + sections_en: + - Integration + sections_zh: + - 集成 + languages: + - en + - zh + - id: blogs + label_en: Blogs + label_zh: 博客 + href_en: en/blogs/index + href_zh: zh/blogs/index + mirror_paths: true + prefixes_en: + - en/blogs/ + prefixes_zh: + - zh/blogs/ + sections_en: + - Blogs + sections_zh: + - 博客 + languages: + - en + - zh + - id: community + label_en: Community + label_zh: 社区 + href_en: en/community/overview + href_zh: zh/community/overview + mirror_paths: true + prefixes_en: + - en/community/ + prefixes_zh: + - zh/community/ + sections_en: + - Community + sections_zh: + - 社区 + languages: + - en + - zh + # Top notice bar (ADK-style). Set enabled: true and non-empty id + message_* to show. + # Bump id when you change copy so users who dismissed the bar see the new notice. + # If the bar does not show: (1) open en/intro.html not only index.html before rebuild; + # (2) change id or clear localStorage key "agentscope-docs-site-notice". + docs_site_notice: + enabled: false + id: "" + variant: "brand" + message_en: "" + message_zh: "" + link_en: "" + link_text_en: "" + link_zh: "" + link_text_zh: "" html_sidebars: "**": - "sidebar/scroll-start.html" - - "sidebar/brand.html" - - "language-switch.html" - - "sidebar/search.html" - "sidebar/navigation.html" - "sidebar/ethical-ads.html" - "sidebar/scroll-end.html" diff --git a/docs/_sphinx_extensions/root_index_notice.py b/docs/_sphinx_extensions/root_index_notice.py new file mode 100644 index 000000000..718361702 --- /dev/null +++ b/docs/_sphinx_extensions/root_index_notice.py @@ -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. + +"""Replace minimal Jupyter Book root index.html with a page that shows docs_site_notice. + +The default root ``index.html`` is only a meta-refresh stub and never runs ``page.html``, +so the notice bar does not appear when users open ``/`` or ``index.html``. This hook +rebuilds that file when it looks like a redirect stub, using the same ``html_context`` +``docs_site_notice`` data as the Jinja template. +""" + +from __future__ import annotations + +import html +import json +import re +from pathlib import Path + + +def _notice_enabled(dn: dict) -> bool: + if not dn: + return False + flag = dn.get("enabled") + if flag is True: + return True + if isinstance(flag, str) and flag.lower() in ("true", "1", "yes", "on"): + return True + return False + + +def _notice_html(dn: dict, redirect_target: str) -> str: + """Return inner HTML (body content) for the notice, or empty string.""" + if not _notice_enabled(dn): + return "" + nid = str(dn.get("id") or "").strip() + if not nid: + return "" + msg = str(dn.get("message_en") or "").strip() or str(dn.get("message_zh") or "").strip() + if not msg: + return "" + variant = dn.get("variant") or "brand" + if variant not in ("brand", "neutral", "accent"): + variant = "brand" + href = str(dn.get("link_en") or "").strip() + link_text = str(dn.get("link_text_en") or "").strip() + link_html = "" + if href and link_text: + if href.startswith("http://") or href.startswith("https://"): + link_html = ( + f'{html.escape(link_text)}' + ) + else: + safe = html.escape(href.lstrip("/"), quote=True) + link_html = f'{html.escape(link_text)}' + + esc_id = html.escape(nid, quote=True) + esc_msg = html.escape(msg) + id_json = json.dumps(nid) + return f""" +
    +
    +

    {esc_msg}

    + {link_html} + +
    +
    +

    Continue to documentation

    +""" + + +def _is_redirect_stub(text: str) -> bool: + t = text.strip().lower() + if not t: + return False + if "\s]+)", text, re.I) + if not m: + return None + return m.group(1).strip() + + +def _redirect_target_from_patched(text: str) -> str | None: + m = re.search(r"location\.replace\(\s*\"([^\"]+)\"\s*\)", text) + if m: + return m.group(1).strip() + m = re.search(r'location\.replace\(\s*("|\')([^"\']+)(\1)\s*\)', text) + if m: + return m.group(2).strip() + return None + + +def _minimal_redirect_stub(target: str) -> str: + return f'\n' + + +def _patch_root_index(app, _exception) -> None: + if _exception is not None: + return + if app.builder.format != "html": + return + outdir = Path(app.outdir) + index = outdir / "index.html" + if not index.is_file(): + return + raw = index.read_text(encoding="utf-8") + target = _redirect_target(raw) or _redirect_target_from_patched(raw) or "en/intro.html" + + ctx = getattr(app.config, "html_context", None) or {} + dn = ctx.get("docs_site_notice") if isinstance(ctx, dict) else None + if not isinstance(dn, dict): + dn = {} + + notice_block = _notice_html(dn, target) + if not notice_block.strip(): + if "docs-site-notice" in raw or "docs-root-redirect-hint" in raw: + index.write_text(_minimal_redirect_stub(target), encoding="utf-8") + return + + if not (_is_redirect_stub(raw) or ("docs-site-notice" in raw and "docs-root-redirect-hint" in raw)): + return + + title = html.escape(str(getattr(app.config, "project", "Documentation"))) + target_json = json.dumps(target) + body = f"""{notice_block} + + +""" + + replacement = f""" + + + + + {title} + + + + +{body} + + +""" + index.write_text(replacement, encoding="utf-8") + + +def setup(app): + app.connect("build-finished", _patch_root_index) + return { + "version": "1.0.0", + "parallel_read_safe": True, + "parallel_write_safe": True, + } diff --git a/docs/_static/custom.css b/docs/_static/custom.css index 36373d76f..7dc21eeb1 100644 --- a/docs/_static/custom.css +++ b/docs/_static/custom.css @@ -20,6 +20,44 @@ margin-bottom: 1rem; } +/* Compact language switch when embedded in the site header */ +.docs-site-header .language-switch { + padding: 0; + margin: 0; + border: none; +} + +.docs-site-header .language-button { + width: auto; + min-width: 5.5rem; + margin: 0; + padding: 0.45rem 0.65rem; + box-shadow: none; +} + +.docs-site-header .language-menu { + left: auto; + right: 0; + min-width: 10rem; + z-index: 5000; +} + +/* Fixed placement (set from tabs.js) escapes header / TOC stacking & overflow */ +.docs-site-header .language-menu.docs-lang-menu--fixed { + z-index: 2147483000; + box-shadow: 0 12px 28px rgb(0 0 0 / 0.18); +} + +/* Open language control stacks above search/theme siblings in the header row */ +.docs-site-header .language-switch { + position: relative; + z-index: 0; +} + +.docs-site-header .language-selector.open { + z-index: 6000; +} + .language-selector { position: relative; font-size: var(--font-size--small); @@ -168,6 +206,11 @@ body[data-current-lang="zh"] { margin-bottom: 0.75rem; } + .docs-site-header .language-switch { + padding: 0; + margin-bottom: 0; + } + .language-button { padding: 0.625rem 0.75rem; } @@ -219,3 +262,437 @@ body[data-current-lang="zh"] { align-items: center; line-height: 0.6; } + +/* Site-wide notice (above header); dismiss state on */ +html.docs-site-notice-dismissed .docs-site-notice { + display: none !important; +} + +/* Root redirect stub page (see _sphinx_extensions/root_index_notice.py) */ +.docs-root-redirect-hint { + margin: 1rem auto 2rem; + text-align: center; + font-size: 0.9rem; +} + +.docs-root-redirect-hint a { + color: var(--color-brand-primary); + font-weight: 600; +} + +.docs-site-notice { + position: sticky; + top: 0; + z-index: 70; + font-size: 0.9375rem; + line-height: 1.45; +} + +.docs-site-notice__inner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: center; + gap: 0.65rem 1.25rem; + max-width: min(100%, 90rem); + margin-inline: auto; + padding: 0.55rem clamp(1rem, 3vw, 2.5rem); + padding-inline-end: 2.75rem; +} + +.docs-site-notice__text { + margin: 0; + text-align: center; + flex: 1 1 12rem; +} + +.docs-site-notice__link { + flex-shrink: 0; + font-weight: 600; + text-decoration: underline; + text-underline-offset: 0.15em; + white-space: nowrap; +} + +.docs-site-notice__link:hover, +.docs-site-notice__link:focus-visible { + text-decoration-thickness: 0.12em; +} + +.docs-site-notice__close { + position: absolute; + top: 50%; + right: clamp(0.5rem, 2vw, 1.25rem); + transform: translateY(-50%); + display: inline-flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + padding: 0; + border: none; + border-radius: 0.35rem; + background: transparent; + color: inherit; + font-size: 1.35rem; + line-height: 1; + cursor: pointer; + opacity: 0.85; + transition: opacity 0.15s ease-out, background-color 0.15s ease-out; +} + +.docs-site-notice__close:hover, +.docs-site-notice__close:focus-visible { + opacity: 1; +} + +.docs-site-notice--brand { + background: linear-gradient(100deg, #0d47a1 0%, #1565c0 42%, #1976d2 100%); + color: #fff; + box-shadow: 0 1px 0 rgb(0 0 0 / 0.12); +} + +.docs-site-notice--brand .docs-site-notice__link { + color: #fff; +} + +.docs-site-notice--brand .docs-site-notice__close:hover, +.docs-site-notice--brand .docs-site-notice__close:focus-visible { + background: rgb(255 255 255 / 0.12); +} + +.docs-site-notice--neutral { + background: var(--color-background-secondary); + color: var(--color-foreground-primary); + border-bottom: 1px solid var(--color-background-border); +} + +.docs-site-notice--neutral .docs-site-notice__link { + color: var(--color-brand-primary); +} + +.docs-site-notice--neutral .docs-site-notice__close:hover, +.docs-site-notice--neutral .docs-site-notice__close:focus-visible { + background: var(--color-background-hover); +} + +.docs-site-notice--accent { + background: color-mix(in srgb, var(--color-brand-primary) 18%, var(--color-background-secondary)); + color: var(--color-foreground-primary); + border-bottom: 1px solid color-mix(in srgb, var(--color-brand-primary) 35%, var(--color-background-border)); +} + +.docs-site-notice--accent .docs-site-notice__link { + color: var(--color-brand-primary); +} + +.docs-site-notice--accent .docs-site-notice__close:hover, +.docs-site-notice--accent .docs-site-notice__close:focus-visible { + background: var(--color-background-hover); +} + +@media (max-width: 640px) { + .docs-site-notice__inner { + padding-block: 0.65rem; + padding-inline: 2.5rem 2.5rem; + } + + .docs-site-notice__text { + flex-basis: 100%; + } +} + +/* Global site header: left = brand + section tabs, right = utilities */ +.docs-site-header { + position: sticky; + top: 0; + /* Above Furo .toc-drawer (50) / overlays so dropdowns are not covered by page chrome */ + z-index: 60; + background: var(--color-background-primary); + border-bottom: 1px solid var(--color-background-border); + overflow: visible; +} + +.docs-site-header__inner { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + column-gap: clamp(1.5rem, 4vw, 3rem); + row-gap: 1rem; + padding: 0.9rem clamp(1.25rem, 4vw, 2.75rem); + max-width: min(100%, 90rem); + margin-inline: auto; + overflow: visible; +} + +.docs-site-header__start { + display: flex; + flex-wrap: wrap; + align-items: center; + column-gap: clamp(1.25rem, 3vw, 2.75rem); + row-gap: 0.75rem; + flex: 1 1 auto; + min-width: 0; +} + +.docs-site-header__brand { + display: inline-flex; + align-items: center; + gap: 1rem; + padding: 0.45rem 1.1rem 0.45rem 0.55rem; + margin: -0.2rem 0; + border-radius: 0.65rem; + text-decoration: none; + color: var(--color-foreground-primary); + font-weight: 600; + flex-shrink: 0; + transition: background-color 0.18s ease-out, color 0.15s ease-out; +} + +.docs-site-header__brand:hover, +.docs-site-header__brand:focus-visible { + text-decoration: none; + color: var(--color-brand-primary); + background-color: var(--color-background-secondary); +} + +.docs-site-header__logo { + display: block; + width: 44px; + height: 44px; + object-fit: contain; +} + +.docs-site-header__title { + font-size: 1.0625rem; + font-weight: 600; + letter-spacing: -0.02em; + line-height: 1.28; + max-width: min(22rem, 42vw); +} + +.docs-site-header__nav-shell { + flex: 1 1 14rem; + min-width: 0; + padding: 0.4rem 0 0.4rem clamp(1.25rem, 2.8vw, 2.35rem); + margin-left: clamp(0.35rem, 1.2vw, 0.85rem); + border-left: 1px solid var(--color-background-border); +} + +.docs-site-header__nav-shell .docs-top-tabs { + margin: 0; + padding: 0; + border: none; +} + +.docs-site-header__actions { + display: flex; + align-items: center; + flex-wrap: wrap; + justify-content: flex-end; + gap: 1rem 1.25rem; + flex: 0 0 auto; + min-width: 0; + padding: 0.2rem 0 0.2rem clamp(1rem, 2.5vw, 2rem); + margin-left: clamp(0.5rem, 1.5vw, 1.25rem); + border-left: 1px solid var(--color-background-border); + overflow: visible; +} + +.docs-header-search { + display: flex; + align-items: stretch; + flex: 1 1 11rem; + min-width: 0; + max-width: 17rem; + border: 1px solid var(--color-background-border); + border-radius: 0.55rem; + background: var(--color-background-secondary); + overflow: hidden; +} + +.docs-header-search:focus-within { + border-color: var(--color-brand-primary); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--color-brand-primary) 35%, transparent); +} + +.docs-header-search__input { + flex: 1 1 auto; + min-width: 0; + border: none; + background: transparent; + padding: 0.55rem 0.75rem; + font-size: 0.9375rem; + color: var(--color-foreground-primary); + font-family: var(--font-stack); +} + +.docs-header-search__input::placeholder { + color: var(--color-foreground-secondary); +} + +.docs-header-search__input:focus { + outline: none; +} + +.docs-header-search__submit { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + padding: 0 0.65rem; + border: none; + border-left: 1px solid var(--color-background-border); + background: var(--color-background-secondary); + color: var(--color-foreground-secondary); + cursor: pointer; + transition: color 0.15s ease-out, background-color 0.15s ease-out; +} + +.docs-header-search__submit:hover, +.docs-header-search__submit:focus-visible { + color: var(--color-brand-primary); + background: var(--color-background-hover); +} + +.docs-header-search__icon { + width: 1.1rem; + height: 1.1rem; + display: block; +} + +.docs-header-searchbox:empty { + display: none; +} + +.docs-site-header__theme .theme-toggle { + padding: 0.35rem; +} + +/* Single theme control in the site header; hide Furo's in-page duplicates */ +.docs-site-header ~ .page .mobile-header .theme-toggle-container, +.docs-site-header ~ .page .content-icon-container .theme-toggle-container { + display: none !important; +} + +/* Avoid duplicating the title next to the hamburger on small screens */ +.docs-site-header ~ .page .mobile-header .header-center { + display: none; +} + +.docs-site-header .docs-top-tabs__list { + gap: 0.35rem 1.125rem; + padding-block: 0.15rem; +} + +.docs-top-tabs__list { + display: flex; + gap: 0.75rem; + overflow-x: auto; + padding-bottom: 0.125rem; + scrollbar-width: thin; +} + +.docs-top-tabs__link { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0.55rem 1.15rem; + border: 1px solid transparent; + border-radius: 999px; + color: var(--color-foreground-secondary); + font-size: 0.9375rem; + font-weight: 600; + line-height: 1.25; + text-decoration: none; + white-space: nowrap; + transition: color 0.15s ease-out, background-color 0.15s ease-out, border-color 0.15s ease-out; +} + +.docs-top-tabs__link:hover, +.docs-top-tabs__link:focus-visible { + color: var(--color-foreground-primary); + background: var(--color-background-secondary); + border-color: var(--color-background-border); + text-decoration: none; +} + +.docs-top-tabs__link.is-active { + color: var(--color-brand-primary); + background: color-mix(in srgb, var(--color-brand-primary) 10%, transparent); + border-color: color-mix(in srgb, var(--color-brand-primary) 30%, var(--color-background-border)); +} + +.sidebar-scroll .sidebar-tree .docs-nav-group-hidden { + display: none !important; +} + +body[data-current-tab="home"] .sidebar-scroll .sidebar-tree .caption, +body[data-current-tab="home"] .sidebar-scroll .sidebar-tree .caption + ul { + display: none !important; +} + +@media (max-width: 960px) { + .docs-site-header__nav-shell { + flex-basis: 100%; + border-left: none; + padding-left: 0; + margin-left: 0; + padding-top: 0.85rem; + margin-top: 0.35rem; + border-top: 1px solid var(--color-background-border); + } + + .docs-site-header__actions { + flex: 1 1 100%; + border-left: none; + padding-left: 0; + margin-left: 0; + justify-content: flex-start; + padding-top: 0.75rem; + margin-top: 0.25rem; + border-top: 1px solid var(--color-background-border); + } +} + +@media (max-width: 768px) { + .docs-site-header__inner { + padding: 0.75rem clamp(0.85rem, 3vw, 1.25rem); + row-gap: 0.85rem; + } + + .docs-site-header__brand { + gap: 0.85rem; + padding-inline: 0.35rem 0.65rem; + } + + .docs-site-header__title { + max-width: none; + } + + .docs-site-header__actions { + justify-content: flex-end; + row-gap: 0.75rem; + } + + .docs-header-search { + flex: 1 1 100%; + max-width: none; + } + + .docs-top-tabs__list { + gap: 0.5rem; + } + + .docs-top-tabs__link { + padding: 0.55rem 0.9rem; + font-size: 0.9rem; + } +} + +@media (prefers-reduced-motion: reduce) { + .docs-top-tabs__link { + transition: none; + } +} diff --git a/docs/_static/notice-bar.js b/docs/_static/notice-bar.js new file mode 100644 index 000000000..1b5fb3278 --- /dev/null +++ b/docs/_static/notice-bar.js @@ -0,0 +1,53 @@ +/* + * 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. + */ + +(function () { + "use strict"; + + var STORAGE_KEY = "agentscope-docs-site-notice"; + + function dismiss(bar) { + var id = bar.getAttribute("data-notice-id"); + if (!id) { + return; + } + try { + localStorage.setItem(STORAGE_KEY, id); + } catch (e) { + /* ignore quota / private mode */ + } + document.documentElement.classList.add("docs-site-notice-dismissed"); + } + + function init() { + var bar = document.querySelector(".docs-site-notice"); + if (!bar) { + return; + } + var btn = bar.querySelector("[data-notice-dismiss]"); + if (btn) { + btn.addEventListener("click", function () { + dismiss(bar); + }); + } + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", init, { once: true }); + } else { + init(); + } +})(); diff --git a/docs/_static/tabs.js b/docs/_static/tabs.js new file mode 100644 index 000000000..e0fa2a9a5 --- /dev/null +++ b/docs/_static/tabs.js @@ -0,0 +1,401 @@ +/* + * 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. + */ + +(function () { + "use strict"; + + const HIDDEN_CLASS = "docs-nav-group-hidden"; + + function getTabsContainer() { + return document.querySelector(".docs-top-tabs"); + } + + function getSidebarTreeRoot() { + return ( + document.querySelector(".sidebar-scroll .sidebar-tree") || + document.querySelector(".sidebar-tree") + ); + } + + function getCurrentPage() { + const container = getTabsContainer(); + if (container && container.dataset.currentPage) { + return container.dataset.currentPage; + } + + return window.location.pathname + .replace(/\/+$/, "") + .replace(/^\/+/, "") + .replace(/\.html$/, ""); + } + + function getCurrentLanguage() { + const path = window.location.pathname; + return path.includes("/zh/") ? "zh" : "en"; + } + + function parseList(value) { + if (!value) { + return []; + } + + return value + .split("|") + .map((item) => item.trim()) + .filter(Boolean); + } + + function getTabLinks() { + return Array.from(document.querySelectorAll(".docs-top-tabs__link")); + } + + function getTabData(link, lang) { + if (!link) { + return null; + } + + return { + id: link.dataset.tabId, + mirrorPaths: link.dataset.mirrorPaths === "true", + prefixes: parseList(lang === "zh" ? link.dataset.prefixesZh : link.dataset.prefixesEn), + sections: parseList(lang === "zh" ? link.dataset.sectionsZh : link.dataset.sectionsEn), + url: lang === "zh" ? link.dataset.urlZh : link.dataset.urlEn, + }; + } + + function matchesPrefix(currentPage, prefix) { + if (!prefix) { + return false; + } + + const normalizedPrefix = prefix.endsWith("/") ? prefix : prefix.replace(/\/+$/, ""); + return currentPage === normalizedPrefix || currentPage.startsWith(normalizedPrefix); + } + + function prefixMatchScore(prefix) { + if (!prefix) { + return 0; + } + + return prefix.endsWith("/") ? prefix.length : prefix.length + 0.1; + } + + function getActiveTabLink(lang, currentPage) { + let bestLink = null; + let bestScore = -1; + + for (const link of getTabLinks()) { + const tabData = getTabData(link, lang); + if (!tabData) { + continue; + } + + for (const prefix of tabData.prefixes) { + if (!matchesPrefix(currentPage, prefix)) { + continue; + } + + const score = prefixMatchScore(prefix); + if (score > bestScore) { + bestScore = score; + bestLink = link; + } + } + } + + return bestLink; + } + + function setActiveTab(link) { + const activeTabId = link && link.dataset.tabId ? link.dataset.tabId : ""; + + getTabLinks().forEach((tabLink) => { + const isActive = tabLink.dataset.tabId === activeTabId; + tabLink.classList.toggle("is-active", isActive); + + if (isActive) { + tabLink.setAttribute("aria-current", "page"); + } else { + tabLink.removeAttribute("aria-current"); + } + }); + + if (activeTabId) { + document.body.dataset.currentTab = activeTabId; + } else { + delete document.body.dataset.currentTab; + } + } + + function clearSidebarGroupVisibility() { + const root = getSidebarTreeRoot(); + if (!root) { + return; + } + + root.querySelectorAll(`.${HIDDEN_CLASS}`).forEach((node) => { + node.classList.remove(HIDDEN_CLASS); + }); + } + + function setSectionVisibility(caption, visible) { + caption.classList.toggle(HIDDEN_CLASS, !visible); + + let sibling = caption.nextElementSibling; + while (sibling && !sibling.classList.contains("caption")) { + sibling.classList.toggle(HIDDEN_CLASS, !visible); + sibling = sibling.nextElementSibling; + } + } + + function filterSidebarByTab(activeLink, lang) { + const root = getSidebarTreeRoot(); + if (!root) { + return; + } + + const captions = Array.from(root.querySelectorAll(".caption")); + if (!captions.length) { + return; + } + + clearSidebarGroupVisibility(); + + if (!activeLink) { + captions.forEach((caption) => setSectionVisibility(caption, true)); + return; + } + + const activeTab = getTabData(activeLink, lang); + const visibleSections = new Set(activeTab && activeTab.sections ? activeTab.sections : []); + + captions.forEach((caption) => { + const sectionName = caption.textContent.trim(); + setSectionVisibility(caption, visibleSections.has(sectionName)); + }); + } + + function updateLanguageSwitch(lang) { + const currentLang = document.getElementById("current-lang"); + if (currentLang) { + currentLang.textContent = lang === "zh" ? "中文" : "English"; + } + + const options = document.querySelectorAll(".language-option"); + options.forEach((option) => { + const optionLang = option.textContent.trim() === "中文" ? "zh" : "en"; + option.classList.toggle("active", optionLang === lang); + }); + + document.body.setAttribute("data-current-lang", lang); + } + + function resetHeaderLanguageMenuLayout(menu) { + if (!menu) { + return; + } + + menu.style.position = ""; + menu.style.top = ""; + menu.style.right = ""; + menu.style.left = ""; + menu.style.bottom = ""; + menu.style.minWidth = ""; + menu.style.maxHeight = ""; + menu.style.overflowY = ""; + menu.classList.remove("docs-lang-menu--fixed"); + } + + function positionHeaderLanguageMenu() { + const header = document.querySelector(".docs-site-header"); + if (!header) { + return; + } + + const selector = header.querySelector(".language-selector"); + const menu = header.querySelector(".language-menu"); + const button = header.querySelector(".language-button"); + if (!selector || !menu || !button) { + return; + } + + if (!selector.classList.contains("open")) { + resetHeaderLanguageMenuLayout(menu); + return; + } + + const br = button.getBoundingClientRect(); + const gap = 6; + menu.style.position = "fixed"; + menu.style.top = `${Math.round(br.bottom + gap)}px`; + menu.style.right = `${Math.max(8, Math.round(window.innerWidth - br.right))}px`; + menu.style.left = "auto"; + menu.style.bottom = "auto"; + menu.style.minWidth = `${Math.max(Math.round(br.width), 160)}px`; + menu.style.maxHeight = `${Math.max(120, Math.round(window.innerHeight - br.bottom - gap - 16))}px`; + menu.style.overflowY = "auto"; + menu.classList.add("docs-lang-menu--fixed"); + } + + function closeLanguageMenu() { + const header = document.querySelector(".docs-site-header"); + const selector = header + ? header.querySelector(".language-selector") + : document.querySelector(".language-selector"); + const button = selector ? selector.querySelector(".language-button") : document.querySelector(".language-button"); + const menu = selector ? selector.querySelector(".language-menu") : document.querySelector(".language-menu"); + + resetHeaderLanguageMenuLayout(menu); + + if (selector) { + selector.classList.remove("open"); + } + if (button) { + button.setAttribute("aria-expanded", "false"); + } + } + + function getFallbackTabUrl(lang) { + const homeTab = getTabLinks().find((link) => link.dataset.tabId === "home"); + if (!homeTab) { + return null; + } + + return lang === "zh" ? homeTab.dataset.urlZh : homeTab.dataset.urlEn; + } + + function buildLanguageTarget(lang) { + const currentLang = getCurrentLanguage(); + if (currentLang === lang) { + return null; + } + + const activeLink = getActiveTabLink(currentLang, getCurrentPage()); + const activeTab = getTabData(activeLink, currentLang); + const activeTargetUrl = lang === "zh" ? activeLink && activeLink.dataset.urlZh : activeLink && activeLink.dataset.urlEn; + + if (activeTab && activeTab.mirrorPaths) { + return ( + window.location.pathname.replace(`/${currentLang}/`, `/${lang}/`) + + window.location.search + + window.location.hash + ); + } + + if (activeTargetUrl && activeTargetUrl !== "#") { + return activeTargetUrl; + } + + return getFallbackTabUrl(lang); + } + + function applyNavigation(lang) { + const currentPage = getCurrentPage(); + const activeLink = getActiveTabLink(lang, currentPage); + setActiveTab(activeLink); + filterSidebarByTab(activeLink, lang); + updateLanguageSwitch(lang); + } + + function scheduleApplyNavigation() { + const lang = getCurrentLanguage(); + const run = () => applyNavigation(lang); + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", run, { once: true }); + } else { + run(); + } + + window.addEventListener("load", run, { once: true }); + window.requestAnimationFrame(() => window.requestAnimationFrame(run)); + setTimeout(run, 0); + window.addEventListener("pageshow", (event) => { + if (event.persisted) { + run(); + } + }); + } + + window.AgentScopeDocs = { + applyNavigation, + }; + + window.toggleLanguageMenu = function toggleLanguageMenu() { + const header = document.querySelector(".docs-site-header"); + const selector = header + ? header.querySelector(".language-selector") + : document.querySelector(".language-selector"); + const button = selector ? selector.querySelector(".language-button") : document.querySelector(".language-button"); + if (!selector || !button) { + return false; + } + + const isOpen = selector.classList.toggle("open"); + button.setAttribute("aria-expanded", isOpen ? "true" : "false"); + + if (isOpen) { + requestAnimationFrame(() => requestAnimationFrame(positionHeaderLanguageMenu)); + } else { + const menu = selector.querySelector(".language-menu"); + resetHeaderLanguageMenuLayout(menu); + } + + return false; + }; + + window.switchLanguage = function switchLanguage(lang, skipRedirect) { + localStorage.setItem("preferred-language", lang); + updateLanguageSwitch(lang); + + if (!skipRedirect) { + const targetUrl = buildLanguageTarget(lang); + if (targetUrl && targetUrl !== window.location.pathname) { + window.location.href = targetUrl; + return false; + } + } + + applyNavigation(lang); + closeLanguageMenu(); + return false; + }; + + document.addEventListener("click", (event) => { + if (!event.target.closest(".docs-site-header .language-selector")) { + closeLanguageMenu(); + } + }); + + function bindHeaderLanguageMenuLayout() { + const schedulePosition = () => { + if (document.querySelector(".docs-site-header .language-selector.open")) { + positionHeaderLanguageMenu(); + } + }; + + window.addEventListener("resize", schedulePosition, { passive: true }); + window.addEventListener("scroll", schedulePosition, true); + } + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", bindHeaderLanguageMenuLayout, { once: true }); + } else { + bindHeaderLanguageMenuLayout(); + } + + scheduleApplyNavigation(); +})(); diff --git a/docs/_templates/docs-site-header.html b/docs/_templates/docs-site-header.html new file mode 100644 index 000000000..537ee50e8 --- /dev/null +++ b/docs/_templates/docs-site-header.html @@ -0,0 +1,62 @@ + + +{# Use div+role=banner so Furo's document.querySelector("header") still targets .mobile-header. #} + diff --git a/docs/_templates/docs-site-notice.html b/docs/_templates/docs-site-notice.html new file mode 100644 index 000000000..39ef21663 --- /dev/null +++ b/docs/_templates/docs-site-notice.html @@ -0,0 +1,59 @@ +{# + Site-wide notice strip (above docs-site-header). Configure under sphinx.config.html_context.docs_site_notice + in _config.yml. Bump `id` when changing copy so returning visitors see the update again. +#} +{% if docs_site_notice is defined and docs_site_notice.get("enabled") and docs_site_notice.get("id") %} + {% set nid = docs_site_notice.id %} + {% set is_zh = pagename is defined and pagename.startswith("zh/") %} + {% set msg = docs_site_notice.get("message_zh") if is_zh and docs_site_notice.get("message_zh") else docs_site_notice.get("message_en", "") %} + {% if msg %} + {% set variant_raw = docs_site_notice.get("variant", "brand") %} + {% if variant_raw == "neutral" %} + {% set variant = "neutral" %} + {% elif variant_raw == "accent" %} + {% set variant = "accent" %} + {% else %} + {% set variant = "brand" %} + {% endif %} + {% set href = docs_site_notice.get("link_zh") if is_zh and docs_site_notice.get("link_zh") else docs_site_notice.get("link_en") %} + {% set link_text = docs_site_notice.get("link_text_zh") if is_zh and docs_site_notice.get("link_text_zh") else docs_site_notice.get("link_text_en", "") %} + +
    +
    +

    {{ msg|e }}

    + {% if href and link_text %} + {% set is_external = href.startswith("http://") or href.startswith("https://") %} + {{ link_text|e }} + {% endif %} + +
    +
    + {% endif %} +{% endif %} diff --git a/docs/_templates/language-switch.html b/docs/_templates/language-switch.html index a2b2b44b3..20838cb86 100644 --- a/docs/_templates/language-switch.html +++ b/docs/_templates/language-switch.html @@ -16,95 +16,12 @@
    -
    - - diff --git a/docs/_templates/page.html b/docs/_templates/page.html new file mode 100644 index 000000000..e12d53048 --- /dev/null +++ b/docs/_templates/page.html @@ -0,0 +1,8 @@ +{% extends "!page.html" %} + +{# Full-width site chrome above Furo's .page (sidebar + main). Per-tab layouts can evolve under .page later. #} +{% block body %} + {% include "docs-site-notice.html" %} + {% include "docs-site-header.html" %} + {{ super() }} +{% endblock %} diff --git a/docs/_templates/top-tabs.html b/docs/_templates/top-tabs.html new file mode 100644 index 000000000..a53caf2ea --- /dev/null +++ b/docs/_templates/top-tabs.html @@ -0,0 +1,37 @@ +{% if docs_top_tabs is defined and docs_top_tabs %} + {% set current_lang = "zh" if pagename.startswith("zh/") else "en" %} + +{% endif %} diff --git a/docs/_toc.yml b/docs/_toc.yml index 7278907ca..e3067758f 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -88,6 +88,26 @@ parts: - file: en/multi-agent/multiagent-debate title: Multi-Agent Debate +- caption: Harness + chapters: + - file: en/harness/overview + title: Overview + +- caption: Integration + chapters: + - file: en/integration/overview + title: Overview + +- caption: Blogs + chapters: + - file: en/blogs/index + title: Blogs + +- caption: Community + chapters: + - file: en/community/overview + title: Community + - caption: 快速开始 chapters: - file: zh/intro @@ -162,3 +182,43 @@ parts: title: Handoffs - file: zh/multi-agent/multiagent-debate title: 多智能体辩论 + +- caption: 集成 + chapters: + - file: zh/integration/overview + title: 概览 + +- caption: 博客 + chapters: + - file: zh/blogs/index + title: 博客 + +- caption: 社区 + chapters: + - file: zh/community/overview + title: 社区 + +- caption: Harness + chapters: + - file: zh/harness/overview + title: 概览 + - file: zh/harness/quickstart/index + title: 快速开始 + - file: zh/harness/architecture + title: 架构 + - file: zh/harness/workspace + title: 工作区 + - file: zh/harness/filesystem + title: 文件系统 + - file: zh/harness/session + title: 会话 + - file: zh/harness/memory + title: 记忆 + - file: zh/harness/sandbox/index + title: 沙箱 + - file: zh/harness/example/index + title: 示例 + - file: zh/harness/tool + title: 工具 + - file: zh/harness/subagent + title: 子 Agent diff --git a/docs/en/agentscope_java.egg-info/PKG-INFO b/docs/en/agentscope_java.egg-info/PKG-INFO new file mode 100644 index 000000000..856c879e8 --- /dev/null +++ b/docs/en/agentscope_java.egg-info/PKG-INFO @@ -0,0 +1,12 @@ +Metadata-Version: 2.4 +Name: agentscope-java +Version: 0.3.0b1 +Summary: AgentScope Java is an agent-oriented programming framework for building LLM applications with JDK 17+. +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +Provides-Extra: dev +Requires-Dist: jupyter-book<2.0.0,>=1.0.4.post1; extra == "dev" +Requires-Dist: sphinx-autoapi>=3.6.0; extra == "dev" +Requires-Dist: sphinx-llms-txt>=0.5.3; extra == "dev" +Requires-Dist: sphinx-sitemap>=2.5.0; extra == "dev" +Requires-Dist: furo>=2025.7.19; extra == "dev" diff --git a/docs/en/agentscope_java.egg-info/SOURCES.txt b/docs/en/agentscope_java.egg-info/SOURCES.txt new file mode 100644 index 000000000..56b5834ab --- /dev/null +++ b/docs/en/agentscope_java.egg-info/SOURCES.txt @@ -0,0 +1,7 @@ +pyproject.toml +setup.py +en/agentscope_java.egg-info/PKG-INFO +en/agentscope_java.egg-info/SOURCES.txt +en/agentscope_java.egg-info/dependency_links.txt +en/agentscope_java.egg-info/requires.txt +en/agentscope_java.egg-info/top_level.txt \ No newline at end of file diff --git a/docs/en/agentscope_java.egg-info/dependency_links.txt b/docs/en/agentscope_java.egg-info/dependency_links.txt new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/docs/en/agentscope_java.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/docs/en/agentscope_java.egg-info/requires.txt b/docs/en/agentscope_java.egg-info/requires.txt new file mode 100644 index 000000000..41172cf29 --- /dev/null +++ b/docs/en/agentscope_java.egg-info/requires.txt @@ -0,0 +1,7 @@ + +[dev] +jupyter-book<2.0.0,>=1.0.4.post1 +sphinx-autoapi>=3.6.0 +sphinx-llms-txt>=0.5.3 +sphinx-sitemap>=2.5.0 +furo>=2025.7.19 diff --git a/docs/en/agentscope_java.egg-info/top_level.txt b/docs/en/agentscope_java.egg-info/top_level.txt new file mode 100644 index 000000000..d7c2cf2d3 --- /dev/null +++ b/docs/en/agentscope_java.egg-info/top_level.txt @@ -0,0 +1,3 @@ +multi-agent +quickstart +task diff --git a/docs/en/blogs/index.md b/docs/en/blogs/index.md new file mode 100644 index 000000000..3b1db386c --- /dev/null +++ b/docs/en/blogs/index.md @@ -0,0 +1,7 @@ +# Blog + +Technical articles, release highlights, and updates from the AgentScope Java team will appear here. + +```{note} +Planned area. Add posts under `en/blogs/` and list them in the table of contents when ready. +``` diff --git a/docs/en/community/overview.md b/docs/en/community/overview.md new file mode 100644 index 000000000..ad78c0920 --- /dev/null +++ b/docs/en/community/overview.md @@ -0,0 +1,7 @@ +# Community + +Links and guidance for getting help, contributing, and connecting with other AgentScope Java users. + +```{note} +Planned content. Discord, GitHub Discussions, and contribution guides will be curated here. +``` diff --git a/docs/en/harness/overview.md b/docs/en/harness/overview.md new file mode 100644 index 000000000..97ac3dc4f --- /dev/null +++ b/docs/en/harness/overview.md @@ -0,0 +1,9 @@ +# Harness runtime + +The **Harness** agent workspace, sandbox, memory, and tooling documentation is maintained in **Chinese** first. Use the link below for the full guide; this page keeps you in the **English** locale of the site. + +

    Open full Harness documentation (中文)

    + +```{note} +If you are translating or need an English edition of Harness, track progress in the repository; the English sidebar only lists this hub page for now. +``` diff --git a/docs/en/integration/overview.md b/docs/en/integration/overview.md new file mode 100644 index 000000000..a429d2358 --- /dev/null +++ b/docs/en/integration/overview.md @@ -0,0 +1,7 @@ +# Integration + +This section will cover third-party integrations, extensions, and ecosystem connectors for AgentScope Java. + +```{note} +Planned content. Structure and pages will evolve here. +``` diff --git a/docs/zh/blogs/index.md b/docs/zh/blogs/index.md new file mode 100644 index 000000000..89880907b --- /dev/null +++ b/docs/zh/blogs/index.md @@ -0,0 +1,7 @@ +# 博客 + +技术文章、版本亮点与团队动态将发布在此。 + +```{note} +规划区域。就绪后在 `zh/blogs/` 下添加文章并在目录中列出。 +``` diff --git a/docs/zh/community/overview.md b/docs/zh/community/overview.md new file mode 100644 index 000000000..8af980a03 --- /dev/null +++ b/docs/zh/community/overview.md @@ -0,0 +1,7 @@ +# 社区 + +获取帮助、参与贡献与连接其他 AgentScope Java 用户的入口与说明。 + +```{note} +规划中。后续可汇总 Discord、GitHub Discussions、贡献指南等信息。 +``` diff --git a/docs/zh/harness/architecture.md b/docs/zh/harness/architecture.md index 6167b1259..cd98fd9ad 100644 --- a/docs/zh/harness/architecture.md +++ b/docs/zh/harness/architecture.md @@ -2,7 +2,7 @@ [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))。 +> 本文聚焦使用者视角的中粒度——讲清"是谁、什么时候、做什么、跟谁协作",不展开调用栈与实现细节;那些放在各子文档([memory](./memory.md)、[workspace](./workspace.md)、[filesystem](./filesystem.md)、[sandbox](./sandbox/index.md)、[subagent](./subagent.md)、[session](./session.md)、[tool](./tool.md))。 ## 1. 顶层结构 @@ -66,7 +66,7 @@ workspace/ |---|---|---| | `LocalFilesystem` | 本地磁盘 | `virtualMode` 锚定 `rootDir` 阻止穿越;无 shell | | `LocalFilesystemWithShell` | 本地 + 宿主 shell | 声明式下对应 `LocalFilesystemSpec` 与**无 `filesystem` 的默认**;`instanceof AbstractSandboxFilesystem` 时注册 `shell_execute` | -| `BaseSandboxFilesystem` / `SandboxBackedFilesystem` | 沙箱后端 | 文件与命令在沙箱内;见 [Sandbox](./sandbox.md) | +| `BaseSandboxFilesystem` / `SandboxBackedFilesystem` | 沙箱后端 | 文件与命令在沙箱内;见 [Sandbox](./sandbox/index.md) | | `RemoteFilesystem` | KV store | 在 `RemoteFilesystemSpec` 下与 `LocalFilesystem` 经 `CompositeFilesystem` 路由;无 shell | | `CompositeFilesystem` | 按前缀路由 | 仅实现 `AbstractFilesystem`(**不**实现 `AbstractSandboxFilesystem`),**不**触发 `ShellExecuteTool`;最长前缀优先 | @@ -74,7 +74,7 @@ workspace/ ## 3. Hook 列表 -下列为 `Builder.build()` 中常见的 harness 内置 hook(**沙箱模式**下会加入 `SandboxLifecycleHook`,见 [Sandbox](./sandbox.md))。`ReActAgent` 按 `priority()` **升序**执行,同优先级时保留装配顺序。 +下列为 `Builder.build()` 中常见的 harness 内置 hook(**沙箱模式**下会加入 `SandboxLifecycleHook`,见 [Sandbox](./sandbox/index.md))。`ReActAgent` 按 `priority()` **升序**执行,同优先级时保留装配顺序。 | Hook | 优先级 | 监听事件 | 默认开启 | 关键依赖 | |------|--------|----------|---------|----------| @@ -153,7 +153,7 @@ workspace/ - **同步路径** `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`)。 +**子 agent 来源**(`Builder.buildSubagentEntries`):工作区 `subagents/*.md`(`AgentSpecLoader` 解析为 `SubagentDeclaration`)/ 编程式 `.subagent(SubagentDeclaration)` / 自定义 `.subagentFactory`。每个子 agent 都是 leaf `HarnessAgent`(`asLeafSubagent()`,不注册 `SubagentsHook`);workspace / filesystem / sysPrompt 由声明与五行判定表决定,见 [子 Agent](./subagent.md)。 **`TaskRepository`** 是任务编排接口(`putTask` / `getTask` / `listTasks(filter)` / `cancelTask`);默认 `DefaultTaskRepository` 内部用线程池 + `CompletableFuture` + `BackgroundTask` 包装状态机(PENDING/RUNNING/COMPLETED/FAILED/CANCELLED)。 diff --git a/docs/zh/harness/example/index.md b/docs/zh/harness/example/index.md new file mode 100644 index 000000000..3b5b00d1f --- /dev/null +++ b/docs/zh/harness/example/index.md @@ -0,0 +1,3 @@ +# 示例 + +本小节收录可运行的示例与场景化 walkthrough。仓库中的示例工程见根目录 `agentscope-examples/`;此处文档将随需要扩展。 diff --git a/docs/zh/harness/filesystem.md b/docs/zh/harness/filesystem.md index f5149fe87..f726abb23 100644 --- a/docs/zh/harness/filesystem.md +++ b/docs/zh/harness/filesystem.md @@ -8,7 +8,7 @@ 1. **工具面**:`FilesystemTool`(及可选的 `ShellExecuteTool`)只认一个 `AbstractFilesystem` 实例;所有路径与执行都经此出口,便于替换实现。 2. **工作区读写的物理落点**:`WorkspaceManager` 读时「优先走 filesystem、未命中再回退本地」;写与上传一律走 filesystem。因此**长期记忆、日流水账、会话日志**等最终落在哪个介质上,由你选的 **模式** 决定。 -3. **多租户与隔离**:`NamespaceFactory` 在每次操作中从 `RuntimeContext.userId` 等来源拼出路径前缀,使同一套代码在**用户 / 会话 / 全局**之间切换存储分片;`RemoteFilesystemSpec` 与 `SandboxFilesystemSpec` 还把 **IsolationScope** 接到「共享 KV」或「沙箱状态键」上,与 [Sandbox](./sandbox.md) 的隔离叙事一致。 +3. **多租户与隔离**:`NamespaceFactory` 在每次操作中从 `RuntimeContext.userId` 等来源拼出路径前缀,使同一套代码在**用户 / 会话 / 全局**之间切换存储分片;`RemoteFilesystemSpec` 与 `SandboxFilesystemSpec` 还把 **IsolationScope** 接到「共享 KV」或「沙箱状态键」上,与 [Sandbox](./sandbox/index.md) 的隔离叙事一致。 ## 三种声明式模式 @@ -17,7 +17,7 @@ | 模式 | 配置方法 | 典型产物 | Shell | 适用场景 | |------|----------|----------|-------|----------| | **1 — 复合 + 共享存储** | `filesystem(RemoteFilesystemSpec)` | `CompositeFilesystem`:工作区根上 **无 shell 的** `LocalFilesystem` + 按前缀路由的 `RemoteFilesystem` | 否 | 多副本要共享 `MEMORY.md`、`memory/`、会话落盘等;**不在宿主执行**不受信 shell | -| **2 — 沙箱** | `filesystem(SandboxFilesystemSpec)` | `SandboxBackedFilesystem` + 生命周期由 [Sandbox](./sandbox.md) 描述 | 是(在沙箱内) | 隔离执行、可恢复沙箱会话、可选快照与分布式 Session | +| **2 — 沙箱** | `filesystem(SandboxFilesystemSpec)` | `SandboxBackedFilesystem` + 生命周期由 [Sandbox](./sandbox/index.md) 描述 | 是(在沙箱内) | 隔离执行、可恢复沙箱会话、可选快照与分布式 Session | | **3 — 本机 + shell** | `filesystem(LocalFilesystemSpec)` 或**不显式配 filesystem** | `LocalFilesystemWithShell` | 是(宿主 `sh -c`) | 单进程/本机、信任环境、简单脚本与测试 | **默认未调用任何 `filesystem(...)` 时** 与 **显式 `filesystem(new LocalFilesystemSpec())`** 等价,即模式 3,根目录为 `workspace`、在宿主上提供 shell。 @@ -31,7 +31,7 @@ ### 模式二:沙箱(`SandboxFilesystemSpec`) -- 见 [沙箱(Sandbox)](./sandbox.md)。要点:对外仍是 `AbstractFilesystem` + 可选 `ShellExecuteTool`(经 `AbstractSandboxFilesystem`),但真实 IO/进程在 `SandboxClient` 侧;`SandboxLifecycleHook` 在每次 `call` 周围 acquire/persist/release。 +- 见 [沙箱(Sandbox)](./sandbox/index.md)。要点:对外仍是 `AbstractFilesystem` + 可选 `ShellExecuteTool`(经 `AbstractSandboxFilesystem`),但真实 IO/进程在 `SandboxClient` 侧;`SandboxLifecycleHook` 在每次 `call` 周围 acquire/persist/release。 ### 模式三:本机 + shell(`LocalFilesystemSpec` 或默认) @@ -147,7 +147,7 @@ HarnessAgent agent = HarnessAgent.builder() ## 相关文档 -- [沙箱(Sandbox)](./sandbox.md) — 沙箱模式原理、`SandboxStateStore`、分布式 +- [沙箱(Sandbox)](./sandbox/index.md) — 沙箱模式原理、`SandboxStateStore`、分布式 - [工具](./tool.md) — `FilesystemTool` / `ShellExecuteTool` 入参 - [工作区](./workspace.md) — `WorkspaceManager` 与两层读 - [架构](./architecture.md) — 与 Hook、RuntimeContext 的协作 diff --git a/docs/zh/harness/overview.md b/docs/zh/harness/overview.md index bb7ffc00b..fcfd2b1ae 100644 --- a/docs/zh/harness/overview.md +++ b/docs/zh/harness/overview.md @@ -113,7 +113,7 @@ mvn -pl agentscope-examples/harness-example exec:java \ **关于 `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` 仅作自管后端的逃生口。 +**扩展方向**:在工作区里放 `KNOWLEDGE.md`、`skills/*/SKILL.md`、`subagents/*.md` 就能分别开启领域知识注入、技能加载、子 agent 编排;`.toolResultEviction(ToolResultEvictionConfig.defaults())` 一行启用大结果卸载;**文件/命令的落点**用 [Filesystem — 三种声明式模式](./filesystem.md#三种声明式模式) 选择 **共享存储、沙箱或本机+shell**;需隔离执行时优先 `filesystem(SandboxFilesystemSpec)`(见 [Sandbox](./sandbox/index.md)),`abstractFilesystem` 仅作自管后端的逃生口。 ## 核心能力 @@ -125,7 +125,7 @@ mvn -pl agentscope-examples/harness-example exec:java \ - **大工具结果卸载** —— 解决 *单次工具返回过大*。`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)。 +- **可插拔文件系统** —— 解决 *agent 的环境如何收敛与隔离*。所有文件工具都走 `AbstractFilesystem`;通过 [三种声明式模式](./filesystem.md#三种声明式模式)(本机+shell、复合+Store、沙箱)或 `abstractFilesystem` 自管;配合 `RuntimeContext.userId` 与 `IsolationScope` 做多租户/会话级隔离。隔离执行与沙箱状态恢复见 [Sandbox](./sandbox/index.md)。 此外还有几项围绕以上能力服务的基础设施:`RuntimeContext` 贯穿整次调用、`MemoryMaintenanceScheduler` 在后台做合并与索引维护、`AgentTraceHook` 统一追踪日志、`AgentSkillRepository` 自动装配 `SkillBox`。 @@ -157,7 +157,7 @@ mvn -pl agentscope-examples/harness-example exec:java \ - [工作区(Workspace)](./workspace.md) — 工作区目录结构与上下文注入 - [记忆(Memory)](./memory.md) — 双层记忆、对话压缩与全文检索 - [文件系统(Filesystem)](./filesystem.md) — 三种声明式模式与 `AbstractFilesystem` 层次 -- [沙箱(Sandbox)](./sandbox.md) — 隔离执行、沙箱状态与分布式选项 +- [沙箱(Sandbox)](./sandbox/index.md) — 隔离执行、沙箱状态与分布式选项 - [子 Agent(Subagent)](./subagent.md) — 子 agent 规格与编排 - [工具(Tool)](./tool.md) — 内置工具参考 - [会话(Session)](./session.md) — 会话持久化与状态恢复 \ No newline at end of file diff --git a/docs/zh/harness/quickstart/index.md b/docs/zh/harness/quickstart/index.md new file mode 100644 index 000000000..1c000f004 --- /dev/null +++ b/docs/zh/harness/quickstart/index.md @@ -0,0 +1,3 @@ +# 快速开始 + +本小节收录 Harness 的入门与最短路径教程。更多页面将随版本逐步补充;在此之前可先阅读 [概览](../overview.md) 与 [架构](../architecture.md)。 diff --git a/docs/zh/harness/sandbox.md b/docs/zh/harness/sandbox/index.md similarity index 94% rename from docs/zh/harness/sandbox.md rename to docs/zh/harness/sandbox/index.md index 0128c7609..d3589d70b 100644 --- a/docs/zh/harness/sandbox.md +++ b/docs/zh/harness/sandbox/index.md @@ -1,6 +1,6 @@ # 沙箱(Sandbox) -[Filesystem](./filesystem.md) 说明了 agent 的「文件与命令」从哪来。当这些操作必须**与宿主进程隔离**、在**可替换的执行环境**(本地 Unix、Docker 等)里完成,并在多次 `call` 之间**恢复同一份工作区状态**时,应选用本文描述的 **沙箱模式**(`filesystem(SandboxFilesystemSpec)`)。 +[Filesystem](../filesystem.md) 说明了 agent 的「文件与命令」从哪来。当这些操作必须**与宿主进程隔离**、在**可替换的执行环境**(本地 Unix、Docker 等)里完成,并在多次 `call` 之间**恢复同一份工作区状态**时,应选用本文描述的 **沙箱模式**(`filesystem(SandboxFilesystemSpec)`)。 ## 1. 沙箱解决什么问题 @@ -23,7 +23,7 @@ ## 3. 隔离维度(`IsolationScope`) -`IsolationScope` 控制**沙箱状态的持久化键**(sandbox 模式)以及**共享存储的命名空间前缀**(store 模式,见 [Filesystem 模式一](./filesystem.md))。两个模式共用同一个枚举,语义一致。 +`IsolationScope` 控制**沙箱状态的持久化键**(sandbox 模式)以及**共享存储的命名空间前缀**(store 模式,见 [Filesystem 模式一](../filesystem.md))。两个模式共用同一个枚举,语义一致。 | 范围 | 持久化键来源 | 缺失时行为 | 典型场景 | |------|------------|----------|---------| @@ -359,11 +359,11 @@ call 3: shell_execute("cat results.csv") → 读 call 2 产生的文件 | 范围 | 并发安全性 | 说明 | |------|-----------|------| | `SESSION` | ✅ 天然隔离 | 每个 session 独占自己的 state slot,不同 session 的请求互不干扰 | -| `USER` | ✅ 默认安全 | 同一用户的多个 session 顺序复用(`checkRunning=true` 保证单实例不并发;多实例下最后写入覆盖,但沙箱本身不会被并发污染) | +| `USER` | ⚠️ 多副本下需要额外保证 | 同一用户的多个 session 顺序复用;单实例下 `checkRunning=true` 已足够,多副本部署时建议配置 `SandboxExecutionGuard` 保护同一 `userId` 对应的 state slot | | `AGENT` | ⚠️ 需要额外保证 | 所有用户/会话共享同一 state slot;多副本下并发写可能导致快照和 state 互相覆盖 | | `GLOBAL` | ⚠️ 需要额外保证 | 与 `AGENT` 同理,范围更大 | -`SESSION` 和 `USER` 范围下,单实例的 `checkRunning=true`(默认)已足够;**`AGENT` 和 `GLOBAL` 在多副本部署时需要显式配置 `SandboxExecutionGuard`** 来串行化对共享 slot 的访问。 +`SESSION` 范围下,单实例的 `checkRunning=true`(默认)已足够;**`USER`、`AGENT` 和 `GLOBAL` 在多副本部署时都建议显式配置 `SandboxExecutionGuard`** 来串行化对共享 slot 的访问。 ### 9.2 `SandboxExecutionGuard` 接口 @@ -395,7 +395,7 @@ Priority 1(`externalSandbox`)和 Priority 2(`externalSandboxState`)不 ### 9.3 内置 Redis 实现 -`RedisSandboxExecutionGuard` 使用 Redis `SET NX PX` 租约实现分布式互斥,**与 `RedisSnapshotSpec` 共用同一 `UnifiedJedis` 实例**,不引入额外依赖: +`RedisSandboxExecutionGuard` 使用 Redis `SET NX PX` 租约实现分布式互斥,**与 `RedisSnapshotSpec` 共用同一 `UnifiedJedis` 实例**,不引入额外依赖。它会把 `IsolationScope` 一并编码进锁 key,因此 `USER`、`AGENT` 和 `GLOBAL` 都会落到各自独立的分布式锁上: ```java UnifiedJedis jedis = new JedisPooled("redis-host", 6379); @@ -424,9 +424,12 @@ HarnessAgent.builder() Redis key 格式为 `:`,例如: +- `myapp:sandbox:lock:user:alice` - `myapp:sandbox:lock:agent:shared-agent` - `myapp:sandbox:lock:global:__global__` +如果你把 `isolationScope` 改成 `USER`,同样应复用这个 guard;此时锁 key 会按 `userId` 分桶,用来保护同一用户在多副本下共享的沙箱 state slot。 + **TTL 说明**:TTL 是安全阀而非正确性保证。若某次 call 超过 TTL,Redis 自动释放锁,下一个等待方可进入——这防止了进程崩溃导致的永久死锁,但不能保证超时 call 本身的状态安全。请将 `leaseTtl` 设置为实际 call 时长(含 LLM 延迟、重试)的合理上界。 ### 9.4 自定义实现参考 @@ -467,12 +470,12 @@ SandboxExecutionGuard jvmGuard = key -> { ## 11. 与三种 Filesystem 模式怎么选 -沙箱是三种**声明式**配置之一。完整对比见 [Filesystem](./filesystem.md#三种声明式模式);此处只给决策要点: +沙箱是三种**声明式**配置之一。完整对比见 [Filesystem](../filesystem.md#三种声明式模式);此处只给决策要点: | 你更需要 | 推荐模式 | |----------|----------| -| 多实例共享 `MEMORY.md`、会话日志等到 KV,**不要**在宿主跑 shell | `RemoteFilesystemSpec`(见 [Filesystem — 模式一](./filesystem.md)) | -| 单进程/本机、信任 shell、**不要**另起沙箱 | `LocalFilesystemSpec` 或默认本机 + shell(见 [Filesystem — 模式三](./filesystem.md)) | +| 多实例共享 `MEMORY.md`、会话日志等到 KV,**不要**在宿主跑 shell | `RemoteFilesystemSpec`(见 [Filesystem — 模式一](../filesystem.md)) | +| 单进程/本机、信任 shell、**不要**另起沙箱 | `LocalFilesystemSpec` 或默认本机 + shell(见 [Filesystem — 模式三](../filesystem.md)) | | **隔离执行**、命令与文件落沙箱、**长会话恢复**、可选**快照 + 集群** | **`SandboxFilesystemSpec`(本文)+ 可选 `sandboxDistributed`** | ## 12. 子 Agent @@ -481,7 +484,7 @@ SandboxExecutionGuard jvmGuard = key -> { ## 13. 延伸阅读 -- [Filesystem](./filesystem.md) — 类层次、三种模式、`abstractFilesystem` 逃生口 -- [工具](./tool.md) — `FilesystemTool`、`ShellExecuteTool` 入参 -- [会话](./session.md) — `Session` 与 `WorkspaceSession` -- [架构](./architecture.md) — Hook 协作与时序 +- [Filesystem](../filesystem.md) — 类层次、三种模式、`abstractFilesystem` 逃生口 +- [工具](../tool.md) — `FilesystemTool`、`ShellExecuteTool` 入参 +- [会话](../session.md) — `Session` 与 `WorkspaceSession` +- [架构](../architecture.md) — Hook 协作与时序 diff --git a/docs/zh/harness/subagent.md b/docs/zh/harness/subagent.md index 0ca2240b0..c7ad449a8 100644 --- a/docs/zh/harness/subagent.md +++ b/docs/zh/harness/subagent.md @@ -2,111 +2,212 @@ ## 作用 -让父 agent 能把“独立、重上下文、可并行”的子任务交出去,不打扰主线。子 agent 是**临时**的 `HarnessAgent` 实例:独立 sysPrompt、独立 Memory、不共享父对话历史,仅返回一条结果作为 `tool_result`;同时支持同步 / 异步两种调用。 +让父 agent 能把"独立、重上下文、可并行"的子任务交出去,不打扰主线。子 agent 是**临时**的 `HarnessAgent` 实例:独立会话、不共享父对话历史,仅返回一条结果作为 `tool_result`;同时支持同步 / 异步两种调用。 + +## 声明 / 定义 / 运行时三层模型 + +``` +声明(Declaration) 定义(Definition) 运行时(Runtime root) +───────────────────── ──────────────────────── ──────────────────────────── +mainWorkspace/ 一个普通 workspace 目录 subagent 实际工作的目录根 + subagents/.md (含 AGENTS.md 等) 由五行判定表确定 +(front matter + 可选 body) 可在仓库内/外/独立项目 ├─ 可以是 defWorkspace + 多个声明可指向同一 definition ├─ 可以是 mainWorkspace + └─ 可以是自动创建的隔离目录 +``` + +**声明**负责"父 agent 需要知道什么"(名字、描述、调度参数);**定义**是 subagent 的能力来源;**运行时**是 subagent 实际读写的目录。 ## 触发 | 时机 | 动作 | |------|------| | `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`。 +| `PreReasoningEvent` | `SubagentsHook` 拼的"Subagents"指南段 + 所有可用 agent_id + **当前 session 最近 async task 摘要** 注入每轮 SYSTEM 消息 | +| reasoning 选中子 agent 工具 | `agent_spawn` / `agent_send` / `agent_list` 走同步路径;`timeout_seconds=0` 走异步,写 `TaskRecord` + 返 `task_id` | +| 后续轮次 | `task_output` / `task_cancel` / `task_list` 优先读 workspace `TaskRecord`(真源),本节点 in-memory future 作加速层 | -## 关键逻辑 - -### Spec 来源与汇集 +## 声明来源 ```mermaid flowchart LR - Built[内置 general-purpose
    镜像父配置 + asLeafSubagent] --> Entries[buildSubagentEntries] - Spec[编程 SubagentSpec
    builder.subagent ] --> Entries + Built[内置 general-purpose
    SHARED 模式,严格镜像父配置] --> Entries[buildSubagentEntries] + Decl[编程 SubagentDeclaration
    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] + Hook --> Spawn[AgentSpawnTool] + Hook --> 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)`,完全控制构建逻辑。 +## 五行判定表 + +workspace 根与 sysPrompt 来源由以下规则确定: -### Spec 的两种描述形式 +| 情形 | sysPrompt 来源 | 运行时 workspace 根 | +|---|---|---| +| 内置 `general-purpose`(强制 SHARED) | mainWorkspace 的 `AGENTS.md` | mainWorkspace | +| 声明含 `workspace.path` + ISOLATED | 外部 def 的 `AGENTS.md` | 该外部 path | +| 声明含 `workspace.path` + SHARED | 外部 def 的 `AGENTS.md` | mainWorkspace | +| 无 `workspace.path` + ISOLATED(默认) | 声明 body(内联 sysPrompt) | `mainWorkspace/agents//workspace/`(自动创建) | +| 无 `workspace.path` + SHARED | 声明 body(内联 sysPrompt) | mainWorkspace | -**Markdown front matter**(`workspace/subagents/research.md`)——推荐: +补充约束: +- **SHARED 模式**下 definition workspace 自带的 `skills/` / `knowledge/` / `MEMORY.md` **一律忽略** +- `tools` 是 **allowlist 过滤器**,只能收窄父 toolkit,不能扩权 +- `workspace.path` 相对路径相对于 mainWorkspace;不允许 `..` 跳出(绝对路径开发者自负) +- 同一 definition workspace 允许被多个声明引用 + +## 声明文件(`workspace/subagents/.md`) + +文件名(去 `.md`)= subagent `name` / agent-id,不在 front matter 里写 `name`。 ```markdown --- -name: research-analyst -description: 调研主题、查找文档、汇总外部信息。 -model: qwen3-max -maxIters: 15 -tools: read_file, grep_files +description: 代码评审专家,识别安全/性能/可读性问题。 +workspace: + mode: isolated # isolated | shared,默认 isolated + path: ./defs/code-reviewer # 可选;相对 mainWorkspace 或绝对路径 +model: qwen3-max # 可选覆写 +maxIters: 12 # 可选(默认 10) +tools: [read_file, grep_files, edit_file] # 可选 allowlist --- -你是一名研究分析师。输出带引用、不确定处要明说。 +仅在 front matter 不含 workspace.path 时,本 body 作为 sysPrompt(轻量内联模式)。 ``` -`AgentSpecLoader.parse` 实际仅读 `name` / `description` / `tools`(逗号分)/ `model` / `maxIters`;body 作为 `sysPrompt`。 -`SubagentSpec` 还有 `workspace` 字段,但当前只在**编程式**(手动 `setWorkspace`)生效,Markdown 里写不会被读。 +**规则**: +- `workspace.path` 有值时 body **必须为空**,sysPrompt 从 `workspace.path/AGENTS.md` 读取 +- `workspace.path` 为空时 body 作为 inline sysPrompt(轻量内联模式) -**编程**: +## 编程式 API ```java -SubagentSpec spec = new SubagentSpec("data-analyst", "SQL / 数据聚合 / 趋势"); -spec.setSysPrompt("你是数据分析专家..."); -spec.setMaxIters(10); - HarnessAgent.builder() - .name("Orchestrator").model(model).workspace(workspace) - .subagent(spec) + .subagent(SubagentDeclaration.builder() + .name("code-reviewer") + .description("审查安全、性能和可读性问题") + .workspace(Path.of("./defs/code-reviewer")) // 与 inlineAgentsBody 互斥 + // 或者:.inlineAgentsBody("你是代码评审专家……") + .workspaceMode(WorkspaceMode.ISOLATED) // 默认 + .model("qwen3-max") + .maxIters(12) + .tools(List.of("read_file", "grep_files")) + .build()) .build(); ``` -### 防递归 + 防超深 +- `.workspace(Path)` 对应声明 `workspace.path` +- `.inlineAgentsBody(...)` 对应轻量内联模式(body 当 sysPrompt) +- 两者互斥;`builder.build()` 阶段校验 + +## 内置 `general-purpose` + +- 不写入 `subagents/` 目录,由 `HarnessAgent.Builder` 内部自动注册 +- 等价于"SHARED 模式 + 无外部 definition"的内置声明 +- **完全镜像** main agent 的 hooks / 工具启用/禁用 / skills / workspace context / execution config / compaction / tool-result-eviction / additionalContextFiles +- 仅保留两个必要差异: + 1. `asLeafSubagent()` — 禁止递归 spawn + 2. 独立 child sessionId +- sysPrompt = Subagent Context 段(identity + rules);`AGENTS.md` 通过 `WorkspaceContextHook` 自动注入 + +## 防递归 + 防超深 + +- 所有 subagent(无论来源)都调了 `Builder.asLeafSubagent()`:`leafSubagent=true` 时 `build()` 不注册 `SubagentsHook`,因此子 agent 无法再 spawn +- `AgentSpawnTool` 额外限制:`MAX_SPAWN_DEPTH = 3` 作为动态保险 + +## RuntimeContext 透传 + +`AgentSpawnTool` 在生成子 agent 时会从父 agent 的 `userIdRef` 读取当前 `userId`,并通过 `DefaultAgentManager.invokeAgent(agent, sessionId, userId, prompt)` 传递给子 agent 的 `RuntimeContext`。子 agent 获得独立 `sessionId`(`sub-`),但 `userId` 与父一致,确保 `USER` 作用域的 isolation key 一致。 + +## Async Task 生命周期 + +``` +putTask() + │ 1. 写 TaskRecord(PENDING) → workspace (agents//tasks/.json) + │ 2. supplyAsync → executor thread + │ 3. 存 BackgroundTask → localTasks[":"] + │ + ▼ executor thread + [check cancelRequested flag] + → updateStatus(RUNNING) → workspace + → taskExecution.get() + → updateStatus(COMPLETED/FAILED) → workspace +``` + +**存储层次** + +| 层次 | 载体 | 作用域 | 持久化 | +|------|------|--------|--------| +| `localTasks` Map | `WorkspaceTaskRepository` | 单 JVM 进程 | ✗ 重启丢失 | +| `agents//tasks/.json` | `WorkspaceManager` → `AbstractFilesystem` | 所有节点 | ✔ | -- `SubagentSpec` 生成的子 agent 都调了 `Builder.asLeafSubagent()`:`leafSubagent=true` 时 `build()` **不注册** `SubagentsHook`,因此子 agent 看不到这些工具,无法再 spawn。 -- 在 `AgentSpawnTool` 里还额外限了一道防线:`MAX_SPAWN_DEPTH = 3`,作为动态保险。 +**分布式行为** -### 调用语义 +- 任务执行 sticky 在创建节点;任何节点均可通过 workspace 读取状态 +- `task_output(block=true)` 在非原始节点降级为"读 workspace 终态",返回 `note: possibly on another node` +- `task_cancel` 同步写 `cancelRequested=true` 到 workspace;原始节点执行器在下一次入口检查此 flag 并中止 + +**与 Filesystem 模式的关系** + +| 模式 | `agents//tasks/` 路由 | 分布式可见性 | +|------|-------------------------------|-------------| +| `RemoteFilesystemSpec`(Mode 1) | 自动路由到 `RemoteFilesystem`(BaseStore) | ✔ 全节点可见 | +| `SandboxFilesystemSpec`(Mode 2) | 写入沙箱 VFS,通过 `SandboxStateStore` 跨调用持久化 | 单沙箱可见 | +| `LocalFilesystemSpec`(Mode 3) | 写本地磁盘 | 单机可见 | + +**模型使用 async task 的关键规则** + +1. 启动后立即返回给用户,**不要**立刻调 `task_output` 轮询 +2. 对话历史里的任务状态是**过时的**,必须用 `task_output(block=false)` 或 `task_list()` 获取最新状态 +3. compaction 后先调 `task_list()` 恢复所有任务 ID 和状态 +4. `SubagentsHook` 每轮把当前 session 的 async task 摘要注入 SYSTEM,模型无需记忆 task_id + +## 调用工具 | 工具 | 作用 | 关键参数 | |------|------|---------| -| `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_spawn` | 生成一个子 agent 跑一件任务 | `agent_id`(必填)、`task`(可选)、`label`(可选别名)、`timeout_seconds` 默认 30s,`0` 走后台 | +| `agent_send` | 给已存在的子 agent 补一条话 | `agent_key`(spawn 返回的句柄)或 `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()` 返回后三者。 +| `task_output` | 取后台任务结果(优先读 live future,否则读 workspace 终态) | `task_id`、`block`(**推荐 false**)、`timeout` 默认 30s | +| `task_cancel` | 取消任务(写 cancelRequested flag 到 workspace) | `task_id` | +| `task_list` | 列当前 session 全量任务(从 workspace 读,compaction 后仍准确) | `status_filter` | ## 配置示例 ```java HarnessAgent orchestrator = HarnessAgent.builder() .name("orchestrator").model(model).workspace(workspace) - .subagent(researchSpec) // (1) 编程 - .subagentFactory("my-specialist", id -> // (2) 自定义工厂 + // (1) 编程声明(isolated + 外部 def workspace) + .subagent(SubagentDeclaration.builder() + .name("code-reviewer") + .description("代码审查专家") + .workspace(Path.of("./defs/reviewer")) + .workspaceMode(WorkspaceMode.ISOLATED) + .build()) + // (2) 编程声明(shared + 内联) + .subagent(SubagentDeclaration.builder() + .name("analyst") + .description("数据分析,与主 agent 共享 workspace") + .workspaceMode(WorkspaceMode.SHARED) + .inlineAgentsBody("你是数据分析师...") + .tools(List.of("read_file", "memory_search")) + .build()) + // (3) 自定义工厂 + .subagentFactory("my-specialist", id -> HarnessAgent.builder().name(id).model(specialModel) .workspace(Path.of("./specialist-workspace")) - .toolkit(customToolkit).build()) - .taskRepository(new RedisTaskRepository(...)) // (可选) + .build()) + // 默认使用 WorkspaceTaskRepository(workspace 存储 + in-memory 加速层) + // 无 workspace 时自动降级为 DefaultTaskRepository(in-memory) + // 自定义:.taskRepository(new DefaultTaskRepository()) // 纯内存,仅限单机测试 .build(); -// (3) workspace/subagents/*.md 会被自动扫描 -// (4) 内置 general-purpose 总是在位 +// (4) workspace/subagents/*.md 会被自动扫描(文件名=agent_id) +// (5) 内置 general-purpose 总是在位 ``` -编排 prompt 中子 agent 如何被选中完全依赖 `description`,尽量明确“何时用 / 何时不用 / 输出形式”。同时 `maxIters` 宜比父 agent 小,避免子任务贪吃 token。 +编排 prompt 中子 agent 如何被选中完全依赖 `description`,尽量明确"何时用 / 何时不用 / 输出形式"。同时 `maxIters` 宜比父 agent 小,避免子任务贪吃 token。 ## 相关文档 diff --git a/docs/zh/harness/workspace.md b/docs/zh/harness/workspace.md index 35c46b859..35ab9bf5f 100644 --- a/docs/zh/harness/workspace.md +++ b/docs/zh/harness/workspace.md @@ -2,7 +2,7 @@ ## 作用 -工作区是 `HarnessAgent` 的"地基":人格、长期记忆、领域知识、子 agent 规格、会话历史、技能定义统一以**目录结构 + Markdown** 的形式落地,不再散落在代码里。 +工作区是 `HarnessAgent` 的"地基":人格、长期记忆、领域知识、子 agent 声明、会话历史、技能定义统一以**目录结构 + Markdown** 的形式落地,不再散落在代码里。 agent 每次推理时,工作区里的几个关键文件会被自动注入到 system prompt;运行过程中的记忆与会话也会按既定路径回写到这里。 @@ -17,25 +17,26 @@ agent 每次推理时,工作区里的几个关键文件会被自动注入到 s ## 目录结构 ``` -workspace/ ← 默认 .agentscope/workspace -├── AGENTS.md ← 人格 / 行为约定(每次注入全文) -├── MEMORY.md ← 整理过的长期记忆(每次注入,受 token 预算) +workspace/ ← 默认 .agentscope/workspace +├── AGENTS.md ← 人格 / 行为约定(每次注入全文) +├── MEMORY.md ← 整理过的长期记忆(每次注入,受 token 预算) ├── knowledge/ -│ ├── KNOWLEDGE.md ← 领域知识入口 -│ └── * ← 其他参考文件,按需 read_file 打开 +│ ├── KNOWLEDGE.md ← 领域知识入口 +│ └── * ← 其他参考文件,按需 read_file 打开 ├── memory/ -│ ├── YYYY-MM-DD.md ← 每日记忆流水账(追加,由 MemoryFlushManager 写入) -│ └── .consolidation_state ← MemoryConsolidator 内部状态 -├── skills//SKILL.md ← 自定义技能 -├── subagent.yml ← 子 agent 规格(可选) +│ ├── YYYY-MM-DD.md ← 每日记忆流水账(追加,由 MemoryFlushManager 写入) +│ └── .consolidation_state ← MemoryConsolidator 内部状态 +├── skills//SKILL.md ← 自定义技能 +├── subagents/.md ← 子 agent 声明(文件名=agent_id,自动发现) └── agents// + ├── workspace/ ← isolated 子 agent 的运行时根(无 workspace.path 时自动创建) └── sessions/ - ├── sessions.json ← 会话索引(id / summary / updatedAt) - ├── .jsonl ← LLM 可见的压缩上下文 - └── .log.jsonl← 完整对话日志(追加) + ├── sessions.json ← 会话索引(id / summary / updatedAt) + ├── .jsonl ← LLM 可见的压缩上下文 + └── .log.jsonl ← 完整对话日志(追加) ``` -> 子 agent 还支持 `workspace/subagents/*.md` 自动发现,详见 [子 Agent](./subagent.md)。 +> 子 agent 三层模型(声明 / 定义 / 运行时)详见 [子 Agent](./subagent.md)。 ## 关键逻辑 diff --git a/docs/zh/integration/overview.md b/docs/zh/integration/overview.md new file mode 100644 index 000000000..f7cfcbe1b --- /dev/null +++ b/docs/zh/integration/overview.md @@ -0,0 +1,7 @@ +# 集成 + +本节将介绍 AgentScope Java 的第三方集成、扩展与生态连接器。 + +```{note} +规划中,后续会在此补充具体页面与目录结构。 +```