An Agent Runtime Built on Spring AI Alibaba: Five Real Engineering Problems and Trade-offs

🇨🇳 中文版

For a personal AI Agent system, the first roadblock is usually not "which model to pick" but "how to execute code safely, how to keep long tasks from getting lost, and how to keep the context window from blowing up." spring-harness fills each of these potholes with a one-container-per-run Docker sandbox, SQLite-backed resumable long-running tasks, context compression with overhead estimated from complete messages, magic-number-validated RAG, and a ReAct loop with manually controlled tool execution plus dependency-layered PSE collaboration. That's why I treat Spring AI Alibaba as the Agent capability layer, and keep pushing spring-harness further down the Runtime / Harness direction — LLM → Agent → Harness → Runtime → Workbench, a route that climbs one step at a time.

Starting Point

spring-harness is a full-stack Agent framework built on Spring AI Alibaba — five interaction modes (Chat / RAG / ReAct Agent / PSE collaboration / Long-running Tasks), all running on Alibaba Cloud Bailian (DashScope) or any OpenAI-compatible endpoint, directly accessible within China with no proxy required. This article doesn't repeat the README's feature list; it covers only the five problems I consider the most real and most reflective of engineering trade-offs: the Docker sandbox, long-running tasks, context compression, RAG, and ReAct / PSE collaboration — the problems they solve are exactly the ones that "never show up in a demo but blow up on the first real task."

Docker Sandbox: One Container per Run, Destroyed After Use

When you let an Agent execute user code, safety comes first. spring-harness neither reuses host processes nor pools containers to save startup overhead — every execution spins up a brand-new container, automatically destroyed via --rm when done:

cmd.add("docker"); cmd.add("run");
cmd.add("--rm");                             // auto-remove the container when done
cmd.add("--cap-drop"); cmd.add("ALL");       // drop all Linux capabilities
cmd.add("--security-opt"); cmd.add("no-new-privileges"); // forbid setuid privilege escalation
cmd.add("--network"); cmd.add("none");       // disable networking entirely
cmd.add("--read-only");                      // read-only root filesystem
cmd.add("--memory"); cmd.add(memoryMb + "m");       // memory limit (512MB default)
cmd.add("--cpus"); cmd.add(String.valueOf(cpus));   // CPU limit (1 core default)
cmd.add("--pids-limit"); cmd.add("100");           // process count cap
cmd.add("--ulimit"); cmd.add("nofile=64:64");      // file descriptor cap

This comes from DockerSandboxExecutor.buildDockerCommand. The truly interesting part is how it handles "isolation while still running compiled languages": the root filesystem is read-only, but /tmp gets its own writable tmpfs with exec allowed, so Java/Go/Rust/C/C++ can write out build artifacts and execute them:

--tmpfs /tmp:rw,size=128m,exec

The code file is mounted into the container read-only via -v /host/path/code.java:/tmp/Main.java:ro (Java code is uniformly named Main.java to match the public class Main source convention), leaving no code behind in the host directory.

Beyond isolation, there are two more gates:

  • Timeout kill: 30 seconds by default; waitFor times out and calls destroyForcibly() directly, so an infinite loop can't hang the main thread.
  • Output throttling: stdout/stderr are each capped at 100KB; once full, the stream is cut off with ...[output limit exceeded, truncated], preventing a single cat of a large file from pushing hundreds of KB into the model context. (The codebase's literal strings are in Chinese; they are shown in English translation throughout this article.)

The sandbox supports 8 languages (python/node/alpine/temurin/golang/rust/gcc images each in place), and every execution first probes with docker info — if the Docker daemon is down, you get a friendly "please make sure the Docker daemon is started" instead of an IOException stack trace.

One boundary worth stating upfront: this design is positioned as lightweight isolation for a personal Agent workbench, not a production-grade code execution sandbox for hostile multi-tenant scenarios — code execution is isolated from the host by default, which is enough for personal use. Readers versed in container security will ask the follow-up questions: rootless mode, seccomp, AppArmor, the Docker daemon's own security boundary. None of those are design goals here, and it's more honest to draw the line myself than to be asked.

Long-Running Tasks: SQLite Persistence + Interruptible + Resumable

Users tolerate a few seconds of waiting in Chat, but a 10-iteration ReAct loop or multi-Agent PSE collaboration easily runs for minutes — asynchronous execution is a must. spring-harness's TaskManager runs tasks on a fixed thread pool (TASK_POOL_SIZE=4), with one design solving four problems: concurrency, interruption, persistence, and tracking.

Independent token accounting. Each task binds its own TokenUsageTracker (ThreadLocal task scope); the top-level ReAct/PSE run and every internal Agent share this one tracker, so concurrent tasks never pollute each other's books — otherwise two tasks running simultaneously would mix their tokens into the same account. Child threads explicitly re-bind:

CompletableFuture.runAsync(() -> {
    TokenUsageTracker.bind(currentTracker());
    try { /* execute + record */ }
    finally { TokenUsageTracker.unbind(); }
}, executor);

Interruptible at any time. cancel() does two things: set an interrupt flag bit + Future.cancel(true). ReAct checks the flag at the top of every loop iteration, PSE before every critical phase — cooperative interruption rather than brutally piercing the thread:

if (cancelled.getAsBoolean()) {
    callback.accept(new ReActStreamEvent("interrupted", iteration + 1, null,
        "Task interrupted by user, executed up to round " + iteration, null, null));
    return "";
}

After interruption, the partially completed results are returned, and the status and checkpoint stay in the task record.

SQLite persistence + restart recovery. LongTaskStore writes tasks (including steps / logs / token stats) into a long_tasks table and fully restores them at application startup. There's an easily overlooked engineering detail here: JDBC Connections are not thread-safe; concurrent writes from multiple tasks over one shared long-lived connection will hit database is locked. So the design is a fresh connection per operation + PRAGMA busy_timeout = 5000 + WAL, so reads and writes never block each other:

st.execute("PRAGMA busy_timeout = 5000");
st.execute("PRAGMA journal_mode = WAL");

During recovery, running/queued tasks (impossible to resume once the process is gone) are marked "interrupted by application restart"; completed tasks past the retention period (30 days by default) are cleaned up on a schedule by TaskCleanupScheduler — privacy protection and DB bloat control in one move. Resuming simply re-submit()s with the same parameters, yielding a new task ID while the old task is preserved untouched for comparison.

Context Compression: Estimating Overhead from the Complete Message, Not Just Text

The most insidious bomb in a long Agent loop is context bloat. spring-harness's ContextGuard has two layers of protection, but what truly sets it apart from "naive truncation" is its overhead estimation method:

public static int estimateMessageChars(Message m) {
    int len = safeLen(m.getText());
    if (m instanceof AssistantMessage am && am.getToolCalls() != null) {
        for (var tc : am.getToolCalls()) len += safeLen(tc.name()) + safeLen(tc.arguments());
    }
    if (m instanceof ToolResponseMessage trm && trm.getResponses() != null) {
        for (var tr : trm.getResponses()) len += safeLen(tr.responseData());
    }
    return len;
}

It must count both the toolCalls' arguments and the ToolResponse's responseData. The code comment states it plainly: estimating by getText() alone once caused an incident of roughly 630K tokens of context overflow. To be precise about what this is: the metric is a character-count estimate, not a real tokenizer — its value lies in covering the overhead of tool calls and tool responses, making the estimate much closer to the true context cost, which is already enough to drive trimming decisions.

How to trim once over threshold? Not simply dropping the oldest messages, but folding them into one lightweight summary — keeping "tool name + truncated params/conclusion + thought," zero LLM calls, zero cost:

[Early tool execution record · compressed]
- Thinking: user wants this CSV analyzed, check the structure first
- Call read-file(top-heat.csv: 0,1000)
  → read-file: 1000 rows × 8 columns, including date/amount...

The key constraints: preserve system(0) and user(1) — the task description is never lost; and anti-nested folding: the folded summary carries an "early tool execution record (compressed)" marker prefix, and when folding again, it is no longer compressed but discarded outright (lowest value), so summaries never get re-folded layer upon layer.

ContextSummaryConfig keeps a higher-tier switch: when CONTEXT_SUMMARY_ENABLED=true, the folded summary is generated semantically by an LLM (following the main model by default, capped at 4000 characters), with higher semantic fidelity at the cost of one extra LLM call per trim and slower runs under free-tier rate limiting — so it defaults to off, using zero-cost lightweight folding. This "zero-cost by default, high quality optional" layered design is very pragmatic.

ContextGuard also handles two closing duties: truncating any single tool output over 8000 characters (so reading a large file can't stuff the context), and stripping text-form tool call markers from the LLM's final output (<tool_call>/invoke/function_call/|<tool_calls_section_begin|> etc.) — some models leak these internal directives into their answers as plain text, and they must never be shown to the user.

RAG: One Chain from Upload Validation to Splitting and Persistence

RAG's engineering challenge isn't only retrieval — it's the gate at upload time. For uploaded PDF/TXT/MD/code files, RagService.validateUpload runs four layers of validation:

  1. Extension whitelist (.pdf/.doc/.docx/.md/common code files, etc.)
  2. Size cap of 50MB
  3. MIME soft check (browsers often send octet-stream, so it's advisory only)
  4. Magic number + content validation — the key to blocking forged extensions: a PDF must start with %PDF-, a DOCX must be a ZIP with PK\x03\x04, a DOC must be OLE with D0 CF 11 E0, and text files have their first 8KB checked for a printable-character ratio ≥ 0.85 — stopping tricks like "rename .exe to .pdf" right at the door.

Splitting is dispatched by file type rather than one-size-fits-all:

  • .md/.markdown → MarkdownTextSplitter: splits by heading hierarchy, preserving Markdown semantics
  • .pdf/.doc/.docx → ParagraphTextSplitter: splits by paragraph
  • everything else → TokenTextSplitter: splits by token count

After vectorization, documents go into SimpleVectorStore, with real-time persistence across three files — the vector store, the document registry, and document contents (for preview). addDocument/deleteDocument flush to disk immediately, and restarts reload from the files automatically. There's also a clearAll() that matters a lot for a personal system: one click wipes all documents and vectors, giving a personal system an explicit data-deletion capability — think of it as a "machine forgetting" capability for a personal setup, an upfront answer to the privacy questions comment sections always ask.

ReAct and PSE: Manually Controlled Tool Execution Is Where Multi-Turn Begins

ReAct: Why the Framework's Automatic Tool Execution Must Be Disabled

Spring AI executes tool calls for you by default, but for a hand-rolled multi-turn ReAct loop you must turn that off — otherwise the framework silently executes each round's tools and feeds results back to the model, and you lose all control over your loop. ReActAgentService does this:

ToolCallingChatOptions.builder()
    .toolCallbacks(getAllTools())
    .internalToolExecutionEnabled(false)  // the framework doesn't execute; we do it manually
    .maxTokens(maxTokens)

The loop structure is then fully under control: think → call tool → observe result → think again, force-stopped after at most MAX_ITERATIONS = 10 rounds. Each round:

  • Tool results are first sanitized via ErrorSanitizer.sanitizeContent (filtering API keys / tokens / secrets, keeping sensitive credentials out of context and storage), then truncated via ContextGuard.truncateToolOutput;
  • After the tool round-trip joins the history, ContextGuard.trimMessages compresses it to prevent bloat;
  • The cancel signal is checked at the top of every round for cooperative interruption;
  • After completion, memoryService.extractAndStore asynchronously extracts long-term memory.

The tool set is deduplicated by name in getAllTools() using a LinkedHashMap, local tools first, MCP tools filling the gaps — the same tool name can never appear twice.

PSE: Planner Decomposition, Dependency Layering, Parallel Where Independent

PseOrchestrator hands the task to the Planner → Specialist → Evaluator trio. It has two "smart laziness" designs:

Parallel execution by dependency layers. After the Planner decomposes the task, buildDependencyLayers groups independent tasks into the same layer; tasks in a layer run in parallel via CompletableFuture, layer by layer (if a dependency cycle appears, the remaining tasks are merged into a single fallback layer and executed together):

while (!remaining.isEmpty()) {
    // deps satisfied → this layer; independent within the layer → parallelizable
    for (PseTask task : remaining)
        if (deps empty || completedNames.containsAll(deps)) currentLayer.add(task);
    ...
}

Fast path for simple tasks. When the Planner decomposes into just 1 subtask, it executes + reviews directly, skipping the full orchestration of overall review and final delivery, saving one LLM round trip.

Every subtask goes through the same cycle: Specialist executes → Evaluator reviews against acceptance criteria (AC) → retry on failure (up to 2 times). Two overall guardrails: MAX_TOTAL_ITERATIONS = 15 against infinite loops, and pse.timeout-seconds = 90 as the default overall timeout — on timeout it doesn't hard-fail but returns the partially completed results, so the user gets something usable instead of a bare error.

Results

These five points all answer the same question: how do you dare truly let an Agent system built for yourself off the leash.

  • The Docker sandbox keeps code execution isolated from the host by default;
  • SQLite-backed long-running tasks keep hour-long multi-Agent collaboration alive across page closes, and resumable;
  • Context compression with overhead estimated from complete messages keeps a 10-round loop from blowing the window;
  • RAG's magic-number validation stops forged files at the moment of upload;
  • The manually-driven ReAct loop and dependency-layered PSE make "multi-turn, multi-role" genuinely controllable.

None of this is feature-stacking. It's the residue of real runs and real pitfalls: container isolation buys determinism, SQLite WAL solves concurrent persistence, ContextGuard was born from the 630K-token incident, and ReAct/PSE pull the Agent's execution process back under your control.

To me, the value of a Harness isn't letting the Agent "run" — it's making it safe to run, stoppable, recoverable, and controllable on real tasks.

The Docker sandbox spins up a new container for every code run — isn't that slow?

Yes, there’s container startup overhead every time; the isolation benefits of multi-language support, no network, and a read-only filesystem align with personal usage frequency in exchange for the certainty of default host isolation. --rm auto-destroys plus a tmpfs /tmp leaves no intermediate state behind.

Why SQLite for long-running tasks instead of in-memory storage?

Not losing tasks across process restarts is a hard requirement. LongTaskStore uses WAL + busy_timeout + a fresh connection per operation to solve concurrent write locks; running tasks are marked interrupted on restart, and completed tasks are auto-cleaned after the retention period.

How does context "folding" differ from just dropping old messages?

Folding keeps a one-line summary of “tool name + truncated params/conclusion + thought” with zero LLM calls; counting only getText() misses the real overhead of toolCalls arguments and tool responses (which once caused an incident of roughly 630K tokens of context overflow). The optional LLM semantic summary is off by default.

Why does ReAct disable the framework's automatic tool execution?

Spring AI’s automatic tool execution quietly feeds each round’s results back to the model, costing you control over the multi-turn loop, interruption, context compression, and tool sanitization. Manual execution (internalToolExecutionEnabled=false) keeps the whole loop transparent and controllable.

What scenarios suit PSE vs. ReAct?

ReAct suits coherent “single-Agent multi-turn think + call tools” tasks; PSE (Planner-Specialist-Evaluator) suits decomposable multi-subtask collaboration, with independent subtasks in the same layer automatically parallelized, backed by a 90-second overall timeout and retry limits.

Comments

Leave a reply

Your email address will not be published. Required fields are marked *

AI Engineering Practices & Open Source Projects

Shop Web Chat Nsbp About Privacy

@ 2026 ESN
沪ICP备2024079226号-1   沪公网安备31010502007082号