跪拜 Guibai
← Back to the summary

The Harness That Gives AI Agents Real Hands: OpenHands' PTY Sandbox, Rebuilt in 500 Lines of Java

In the first three chapters, we gave the Agent a brain that can think, but never gave it hands and feet that can work. In this chapter, we put the Agent into an "assembly protective kit" and let it actually modify files, run commands, and read output — and it can't break this machine.

Swarm gave it the loop, LangGraph gave it determinism, Letta gave it memory, and what OpenHands gives is the Harness that lets the Agent have a "physical world entry" for the first time.


1. Preface: The brain is there, but what about the hands and feet?

Close the book for a moment and see where we've built to:

Taken together, these three chapters push the industry's most glaring problem right onto the table:

The Agent can't do work in the physical world.

It's not that it "doesn't want to"; it's that physically, it can't do it safely. You tell it to modify real code, it runs rm -rf /, and the system is destroyed. You tell it to cd src, and the next command returns to the root directory — because each of its commands spawns a new process that knows nothing about the others. These two things are the entirety of the chasm between a "demo-grade Agent" and a "production-ready Agent".

So the question is: What does an Agent need to reach into the real world?

The answer is one word: Harness.

Not a single line of code, but a complete "assembly protective kit" — isolation, session, trimming, fault tolerance. This four-piece kit turns "letting an Agent work" from a death-defying acrobatic act into routine business.

The protagonist of this chapter is OpenHands (formerly OpenDevin) — an industrial-grade system that truly stuffs an Agent into a Docker container and lets it modify real repositories for months and years. We'll strip out its most essential Harness primitives, re-implement them in about 500 lines of strongly-typed code with JDK 25 and zero dependencies, and then watch with our own eyes as the Agent inherits state, trims logs, and self-corrects inside a long-lived bash session.


2. Concept Distillation: What is a Harness? It's not a certain line of code; it's an architectural kit

Many tutorials talk about "giving an Agent tools" and what they mean is registering functionsdef ls(path) and then stuffing it into a tools list. But that's not what OpenHands teaches. What it teaches is the physical pillars: an Agent's capacity to act stands on these four pillars.

2.1 First Pillar: Physical Sandbox Isolation

The reason rm -rf / is terrifying is that it really deletes. An industrial-grade Agent's first reaction is not "educate the model not to do that", but physically make it impossible — put the entire execution environment into a container. If the container dies, you replace it with a new one; the host machine is unscathed.

OpenHands' default approach: one Docker container per session (openhands-runtime-{sid}). All of the model's commands run inside the container, and the container's filesystem, network, and permissions are all isolated from you. This is how "breaking the world" is physically cancelled.

Isolation is not a security patch; it's an architectural premise. With it, the model is qualified to "let go and act".

2.2 Second Pillar: PTY Session State Inheritance (Session Persistence)

This one makes many people pause the first time they hear it: after cd src, the next pwd must still be inside src.

Sounds obvious? Unfortunately, if every command is bash -c "cmd" starting a new process, this is impossible — the two processes don't know each other; cd is the dying words of the previous process. OpenHands' solution is one word: long-lived connection. A resident bash always lies inside the container; all commands are fed to this same process, so the working directory, environment variables, and shell functions physically inherit across multiple executions.

OpenHands' official documentation (recorded by DeepWiki), with the code landing point being the resident tmux pane in Section 3:

"Runtimes maintain shell state across multiple actions, allowing sequential command execution… variables or directory changes persist." — The runtime maintains shell state across multiple actions; variables and directory changes persist.

One session = one living bash. This is the essence of "PTY interaction": not faking a terminal, but giving the Agent a shell that remembers the last conversation.

2.3 Third Pillar: Log Anti-Explosion Trimming (Log Trimming)

The output of real commands is a horror movie: tail -f can spew hundreds of thousands of lines in one go; a single CI log easily reaches hundreds of MB. If you take it all and stuff it into Context, the token window explodes instantly, and the Agent gets amnesia on the spot — worse than not executing at all.

The harness strategy: keep the first N lines (intent and start) + the last N lines (result and status) + lines hitting keywords (Traceback / Error / Exception — not a single critical stack frame lost), trim the middle, and leave an explicit placeholder stating how many lines were saved.

Trimming is not losing information; it's preserving information in a more memory-efficient way.

2.4 Fourth Pillar: Defensive Guardrails

A model is not a compiler; its output can go bad at any moment: fences open but not closed, ```bash misspelled as ```she bash, commands that never end. A real harness' attitude toward all of this is:

Four pillars, summed up in one sentence:

Isolation lets the Agent dare to act, session lets the Agent remember, trimming lets the Agent see everything, guardrails keep the Agent from breaking.


3. Stripping the Noise: The Truly Important 5% of OpenHands' Source Code

First, let's clarify which source code this article reads: OpenHands split its repositories in 2026; the classic-era openhands/runtime/ is no longer on the current branch. We are analyzing the new-generation OpenHands/software-agent-sdk main repository (commit 88afa9af, 2026-08), local path harness/openhands/software-agent-sdk/. Open it directly if you want to cross-reference the original.

Today's OpenHands is a behemoth: REST API, Web frontend, Playwright, helm chart, dozens of runtime implementations, massive telemetry instrumentation. If you clone it and read line by line, you'll likely die around line 2000 amidst some Docker SDK parameter splicing after about two weeks.

90% of a large framework's code is engineering infrastructure; what truly determines "what it looks like" is only a small handful of design ideas.

For OpenHands, this small handful falls in only three places:

software-agent-sdk/
├── openhands-sdk/openhands/sdk/event/base.py                              # ★ Event base class (discriminated union): the Agent's event hub
├── openhands-sdk/openhands/sdk/event/llm_convertible/action.py            # ★ Action: command execution intent
├── openhands-sdk/openhands/sdk/event/llm_convertible/observation.py       # ★ Observation: execution observation
├── openhands-tools/openhands/tools/terminal/constants.py                  # ★ Sentinel markers ###PS1JSON###/###PS1END### + output cap
├── openhands-tools/openhands/tools/terminal/metadata.py                   # ★ PS1 embedded JSON metadata (exit_code=$? / pid / pwd)
├── openhands-tools/openhands/tools/terminal/terminal/tmux_terminal.py     # ★ Real PTY = tmux pane, PS1 injection
├── openhands-tools/openhands/tools/terminal/terminal/terminal_session.py  # ★ Persistent session execute(): command only ends when PS1END is read
├── openhands-sdk/openhands/sdk/utils/truncate.py                          # ★ Output anti-explosion: trim middle, keep head and tail + save to disk for continued reading
└── openhands-workspace/openhands/workspace/docker/workspace.py            # DockerWorkspace: container sandbox

3.1 First Item: The Event System — The Agent's Central Nervous System

Everything in OpenHands is not a "direct call" but an event. The model spits out an Action (wants to run a command) → written into the event stream → the runtime consumes it → produces an Observation (what it saw) → the model reads it. Action and Observation alternate in the event stream like breathing.

In the current SDK, the event base class is a discriminated union (openhands-sdk/openhands/sdk/event/base.py):

class Event(DiscriminatedUnionMixin, ABC):
    """Event base class; discriminator field = concrete class name."""

The command Action is now called TerminalAction (openhands-tools/openhands/tools/terminal/definition.py), with the core field being just one command: str — its predecessor was the classic-era CmdRunAction. The model's sole channel for speaking to the terminal is this structured Action object.

The value of this abstraction: the model's "thoughts" and the environment's "feedback" are decoupled into two kinds of events; anyone can subscribe, anyone can replay, anyone can audit. In our re-implementation, we keep only its leanest form — a thread-safe double-ended queue + a set of subscribers.

3.2 Second Item: Persistent Bash Session + Sentinel Protocol (PS1 Injected JSON Metadata)

This is the soul of OpenHands' execution layer and the direct source of the "physical hands and feet" in this chapter's title. Two hard facts:

Fact One: The session is a long-lived connection; state physically inherits. Commands are not run by starting a new process each time, but executed inside a resident tmux pane (terminal/tmux_terminal.py, TMUX_SOCKET_NAME = "openhands"). cd / export naturally persist across multiple executes — this is "session persistence".

Fact Two: The sentinel is not echo; it's modifying the PS1 prompt. OpenHands replaces bash's entire PS1 with a block of JSON metadata (metadata.py), marked by these two constants (constants.py):

CMD_OUTPUT_PS1_BEGIN: Final[str] = "\n###PS1JSON###\n"
CMD_OUTPUT_PS1_END:   Final[str] = "\n###PS1END###"

And inside that JSON, the exit code and directory are evaluated by bash itself:

json_str = json.dumps({
    "pid": "$!",
    "exit_code": "$?",          # bash evaluates $? to the real exit code when rendering PS1
    "working_dir": r"$(pwd)",   # pwd likewise
    # ...
})

Every time bash finishes executing a command, it renders PS1 once — so the JSON wrapped by ###PS1JSON### appears at the end of the screen carrying the real exit code. The session layer polls the screen in a while True loop, and as soon as it sees a new ###PS1END### appear, it knows this command has ended (terminal_session.py):

if (not sent_command or output_changed_since_command) and (
    current_ps1_count > initial_ps1_count
    or cur_terminal_output.rstrip().endswith(CMD_OUTPUT_PS1_END.rstrip())
):
    return self._handle_completed_command(...)

Then metadata.py uses regex to extract the JSON. The default value for exit_code is -1, and it's also set to -1 if parsing fails — this is exactly the code origin of the convention "If a bash command returns exit code -1, this means the process is not yet finished" (OpenHands PR #3653): when the previous command is still running, a new command is rejected and returns an observation with -1; the model can only send empty commands to fetch subsequent logs, send is_input=true to feed STDIN, or send C-c to interrupt (TIMEOUT_MESSAGE_TEMPLATE).

3.3 Third Item: Output Anti-Explosion + Timeout Guardrails

Command output is not taken wholesale. In the real code, there are two layers of protection:

Additionally, in the current version, the model's output no longer relies on parsing ```bash code blocks — it's **strongly-typed tool calling**; the schema of TerminalAction.command is the protocol. The remnants of defensive parsing still exist in definition.py's looks_like_python_literal_argument(): it detects when the model stuffs Python/JSON literals into the command field, recognizes it at a glance, and directly rejects it. Code block parsing is the CodeAct-era form we re-implement; we keep it here to clearly explain the evolutionary starting point of "format as protocol".

These three items together are that "5%". Everything else — Docker SDK parameters, k8s orchestration, frontend UI, telemetry instrumentation — is engineering scaffolding built around these three core ideas.


4. Minimal Re-implementation: Hand-Writing OpenHands' Core Harness Based on JDK 25

The concepts are clear; now we build. The full re-implementation is in harness/openhands/java_openhands_harness/, zero dependencies, about 600 lines of core code. Not a single line of "AI magic" — all processes, pipes, queues, and regex. Four components, exactly corresponding to the four pillars. I also wrote a Groovy version (groovy_openhands_harness/), the same set of primitives, one file 333 lines, see 4.6.

It benchmarks against the current SDK from Section 3, but makes two pedagogical simplifications: simplifies the PS1 injection + JSON metadata sentinel into a single line echo ___HARNESS_END___ $?; simplifies the strongly-typed TerminalAction into ```bash code block parsing (CodeAct-era form).

java_openhands_harness/src/harness/
├── HarnessEvent.java            # Event types: sealed interface + 4 records (Action / Observation)
├── EventStream.java             # Event bus: thread-safe double-ended queue + subscribers
├── PersistentBashSandbox.java   # ★ Long-lived bash sandbox + sentinel protocol (First and Second Pillars)
├── ObservationTrimmer.java      # ★ Log anti-explosion trimmer (Third Pillar)
├── DefensiveParser.java         # ★ Crash-proof degrading parser (Fourth Pillar)
├── MockLlm.java                 # Fake LLM: genuinely reads the event stream to make decisions
└── HarnessAgentController.java  # ★ Main control loop: virtual-thread-driven ReAct

4.1 Component One: PersistentBashSandbox (Long-Lived Connection + Sentinel Protocol)

First, look at the skeleton. The key is in the constructor: the process is spawned only once, using two virtual threads to continuously drain stdout / stderr into the same shared queue — these two drainers are the guarantee that the entire sandbox won't die:

private void spawn() {
    ProcessBuilder pb = new ProcessBuilder("/bin/bash");
    pb.redirectErrorStream(false);            // Must separate: need to precisely intercept stdout / stderr
    process = pb.start();
    stdin = new OutputStreamWriter(process.getOutputStream(), StandardCharsets.UTF_8);

    // Two virtual threads: one drains stdout, one drains stderr. They only enqueue, never block bash.
    Thread.ofVirtual().name("drain-" + sessionId + "-out").start(() -> drain(process.getInputStream(), "out"));
    Thread.ofVirtual().name("drain-" + sessionId + "-err").start(() -> drain(process.getErrorStream(), "err"));
}

Why must we open two drainers? Because pipes are blocking. If bash writes a bunch to stderr and you only read stdout, once the stderr buffer is full, bash is pinned dead, and you'll never get the next thing from stdout either — deadlock. Two drainers each move lines from their stream into an in-memory queue; bash never stops because "no one is reading me". This is a required lesson for any long-lived connection process.

Then comes the Sentinel Protocol — the absolute protagonist of this chapter. Before executing a command, first rewrite the command into "command + sentinel":

/** Rewrite the command into one line of "command body + sentinel"; the sentinel captures the real exit code $?. */
private String buildScript(String command) {
    String trimmed = command == null ? "" : command.stripTrailing();
    if (trimmed.endsWith(";")) {
        trimmed = trimmed.substring(0, trimmed.length() - 1).stripTrailing();
    }
    if (trimmed.isBlank()) {
        return "echo " + sentinel + " 0";
    }
    return trimmed + "; echo " + sentinel + " $?";   // Sentinel line = end marker + exit code
}

Thus ls /nope actually executes in bash as ls /nope; echo ___HARNESS_END___s0 $?, and the last line of stdout is inevitably ___HARNESS_END___s0 2. When the reading end reads this line, it precisely knows two things: everything up to here is this command's output, and 2 is the exit code. The execution loop:

if ("out".equals(tagged.stream())) {
    // Judge sentinel first, then enqueue: the sentinel line is the "end marker" and must never leak into business output
    if (isSentinel(tagged.line())) {
        sentinelLine = tagged.line();
        break;
    }
    stdout.add(tagged.line());
} else {
    stderr.add(tagged.line());
}

Note that judge sentinel first, then add to stdout order — this was taught to me by a real bug during testing: initially I added the sentinel line to stdout first and then judged, resulting in the sentinel line leaking into business output; ___HARNESS_END___s0 mixed into the logs, and once because it happened to contain the substring err, it polluted stderr assertions. The correct approach for the protocol is to completely separate the marker from the data at the frame level.

Timeout is also a hard guardrail: sleep 5 with a 300ms timeout; the reading end can't wait for the sentinel, so it returns an observation with timedOut=true and destroys the entire process and rebuilds the sandbox — absolutely never leaves a stuck command in a thread.

4.2 Component Two: EventStream + Event Types (sealed + record pattern matching)

Events are the Agent's "breathing". We first use a sealed interface to lock down the event types into four:

public sealed interface HarnessEvent permits HarnessEvent.CmdRunAction,
        HarnessEvent.CmdOutputObservation,
        HarnessEvent.InvalidFormatObservation,
        HarnessEvent.AgentTextMessage {
    record CmdRunAction(String id, String command) implements HarnessEvent {}
    record CmdOutputObservation(String actionId, String output, String stdout, String stderr,
                                int exitCode, long tookMillis, boolean timedOut,
                                boolean trimmed, int rawLineCount) implements HarnessEvent {}
    record InvalidFormatObservation(String actionId, String rawOutput, String reason,
                                    String guidance) implements HarnessEvent {}
    record AgentTextMessage(String role, String content) implements HarnessEvent {}
}

▍Highlight: sealed + record turns "exhaustive matching" into a compile-time guarantee. Because the event types are locked down by sealed, the switch on events in the controller can omit default — all four event types are listed; the compiler knows there's no omission. If a new event type is added in the future, the compiler will error at every switch and force you to complete it. Errors appear at compile time, not in the middle of the night in production. This is the meaning of strong typing: not typing a few more characters, but erasing a whole class of bugs from runtime.

EventStream is lean to the point of having only two members: a ConcurrentLinkedDeque to hold events, and a CopyOnWriteArrayList to hold subscribers. publish() enqueues + notifies; history() returns an immutable snapshot for the model to read.

4.3 Component Three: ObservationTrimmer (Anti-Explosion Trimming)

Before a hundred thousand lines of logs enter Context, they pass through the trimmer. The strategy is completely consistent with the second pillar — head N lines + tail N lines + key lines, explicit placeholder in the middle:

// Head (the start of the command, often the intent)
// Middle: lines hitting keywords (Traceback / Error / Exception… not a single frame lost)
sb.append("… [ObservationTrimmer] trimmed ")
  .append(removed).append(" lines (key lines retained below)…\n");
// Tail (the end of the command, often the result and status)

The result is passed back using a record — what was kept, what was trimmed, clear at a glance:

public record TrimmedResult(String text, int keptHead, int keptTail, int keptImportant,
                            int removedLines, int totalLines, boolean truncated) { ... }

When the controller writes the trimmed observation back into the event stream, it also includes a summary like [ObservationTrimmer] retained head40 + tail40 + key1 lines, trimmed 99920/100001 lines totalletting the model know that what it sees is a trimmed world; this is the essential difference between "trimming" and "losing data".

4.4 Component Four: DefensiveParser (Crash-Proof Degrading Parser)

The model outputs ```bash code blocks; the parser is responsible for stripping the command. Iron rule: parsing never throws exceptions, only degrades, using a sealed interface to lock parsing results into three types:

public sealed interface ParsedAction permits ParsedAction.RunBash,
        ParsedAction.Malformed,
        ParsedAction.PlainText {
    record RunBash(String codeBlock) implements ParsedAction {}
    record Malformed(String rawOutput, String reason, String guidance) implements ParsedAction {}
    record PlainText(String text) implements ParsedAction {}
}

Judgment order: has a legally closed ```bash block → RunBash; **a fence appeared but no legal block could be resolved** (not closed / tag is not bash or sh / content empty) → Malformed, with reason + correction guidance; no fence at all → PlainText (the model is just talking). The regex only recognizes bash / sh / empty tags; ```python is never treated as a command.

And that Malformed gets rewritten by the controller into an InvalidFormatObservation and thrown back into the event stream — bad input didn't kill the Agent; it became a signal driving self-correction. This is the entire essence of the fourth pillar.

4.5 Main Control Loop: HarnessAgentController (Virtual-Thread-Driven ReAct)

Finally, weld the four pillars together. One run() is a standard ReAct loop, and every command execution step runs on a virtual thread:

/** Virtual thread pool: one new virtual thread per task; blocking I/O no longer occupies system threads. */
private static final ExecutorService VIRTUAL =
        Executors.newVirtualThreadPerTaskExecutor();

How is the event stream consumed? Using a switch with sealed interface + record pattern matching — this is the most straightforward way to write it in JDK 21+, exhaustive, no default, pattern as destructuring:

switch (parsed) {
    // Legal command: first write Action into event stream → virtual thread executes → trim to prevent explosion → write observation back to event stream
    case ParsedAction.RunBash run -> {
        var action = new HarnessEvent.CmdRunAction("run-" + stepCount, run.codeBlock());
        stream.publish(action);
        HarnessEvent.CmdOutputObservation raw = executeOnVirtualThread(run.codeBlock());
        HarnessEvent.CmdOutputObservation obs = trimmer.maybeTrim(raw);
        stream.publish(obs);
    }
    // Format damaged: degrade to InvalidFormatObservation, let the model self-correct
    case ParsedAction.Malformed malformed -> {
        stream.publish(new HarnessEvent.InvalidFormatObservation(
                "parse-" + (++parseSeq), malformed.rawOutput(),
                malformed.reason(), malformed.guidance()));
    }
    // Plain text: final reply, this round ends
    case ParsedAction.PlainText plain -> {
        stream.publish(new HarnessEvent.AgentTextMessage("assistant", plain.text()));
        return plain.text();
    }
}

▍Highlight: Virtual threads are the antidote prepared for I/O-intensive Agents. Command execution is pure blocking I/O — waiting for bash, waiting for pipes, waiting for timeout. In the traditional thread model, ten concurrent Agents are ten pinned OS threads; virtual threads collapse "waiting" onto extremely small carriers; thousands of blocking tasks can queue on dozens of platform threads. Under JDK 25, this single line newVirtualThreadPerTaskExecutor() sets up the concurrency foundation.

Add to that a self-correction closed loop: model spits bad format → parser Malformed → controller emits InvalidFormatObservation → model reads this observation → next round re-sends correct format → execution succeeds. The entire process threw not a single exception; the Agent repaired itself.

Run menu (Java):

Example Command
Four-act core demo ./run.sh harness.examples.HarnessDemo
28-assertion test ./run.sh harness.test.TestHarness

4.6 Same Core, How Short Is the Groovy Version?

Like the previous three chapters, I wrote another Groovy version of the same core (groovy_openhands_harness/). The Java version: 8 files, 793 logic lines; the Groovy version: one file, 333 lines, nearly 60% shorter. The gap isn't in "functionality"; it's in "ceremony".

sealed interface + record → one HarnessEvent (type + data map):

class HarnessEvent {
    String type                          // 'cmd_run' / 'cmd_output' / 'invalid_format' / 'text'
    Map data
    static HarnessEvent action(String id, String command)   { new HarnessEvent(type: 'cmd_run', data: [id: id, command: command]) }
    static HarnessEvent observation(CmdOutputObservation o) { new HarnessEvent(type: 'cmd_output', data: [observation: o]) }
    static HarnessEvent text(String role, String content)   { new HarnessEvent(type: 'text', data: [role: role, content: content]) }
    // ...
}

Sandbox spins up two drainers; Java needs to write the entire spawn(); Groovy, two lines:

Thread.ofVirtual().name("drain-${sessionId}-out").start { drain(process.getInputStream(), 'out') }
Thread.ofVirtual().name("drain-${sessionId}-err").start { drain(process.getErrorStream(), 'err') }

Controller dependency injection; Java fills constructors; Groovy, one line of map:

new HarnessAgentController(stream: stream, sandbox: sandbox, llm: new MockLlm(),
        trimmer: new ObservationTrimmer(), parser: new DefensiveParser())

The same sentinel protocol; Groovy version's core, four lines:

if (tagged.stream == 'out') {
    if (tagged.line.startsWith(sentinel + ' ')) { sentinelLine = tagged.line; break }
    stdout << tagged.line
} else {
    stderr << tagged.line
}

It runs the same four-act plot; the output is character-for-character identical to the Java version (see next section). Run menu (Groovy):

Example Command
Four-act core demo ./run.sh examples/01_harness_demo.groovy
25-assertion test ./run.sh test/TestHarness.groovy

Why does industry love using dynamic languages for prototyping, but production bases return to strong typing? Groovy's map construction and closures erase the boilerplate of "configuration" and "callbacks", fastest for validating ideas; but sealed + record's exhaustive matching turns "missing one event type" from a runtime error into a compile-time error — when your harness needs to support dozens of Action types, this bill is worth paying.


5. Runtime Verification: Watching the Agent Inherit State and Self-Correct in a Real PTY

Run ./run.sh harness.examples.HarnessDemo. The following is real console output (pid varies with runtime environment).

Opening: A resident bash is spun up; the sentinel protocol is in place.

== HarnessDemo: Giving the Agent Physical Hands and Feet ==

[harness] Resident bash spun up: pid=9906, sentinel protocol = echo ___HARNESS_END___s0 $?
[harness] All subsequent commands are fed to this same process; no subprocesses are spawned.

Step 1: Create directory + export environment variable

──────── Act 1 · Create directory + export ENV_KEY=JDK25 ────────
[user] Please create the project under /tmp/harness_workspace and export the environment variable ENV_KEY=JDK25
  [llm] Decision → ```bash mkdir -p /tmp/harness_workspace && cd /tmp/harness_workspace && export ENV_KEY=JDK…
  [controller] Hit bash action, virtual thread submitted for execution…
  [observation] run-1 exit=0 124ms(stdout 1 line)
      | READY

Note that exit=0 is not guessed; it's extracted from the sentinel line ___HARNESS_END___s0 0.

Step 2: Next command pwd, witnessing physical state inheritance

──────── Act 2 · Follow-up: Are the directory and variable still there? ────────
[user] Tell me what directory you're in now and what ENV_KEY is.
  [llm] Decision → ```bash pwd && echo "ENV_KEY=$ENV_KEY" ```
  [controller] Hit bash action, virtual thread submitted for execution…
  [observation] run-2 exit=0 120ms(stdout 2 lines)
      | /tmp/harness_workspace
      | ENV_KEY=JDK25

  [assistant] Current directory is /tmp/harness_workspace, ENV_KEY=JDK25 (the previous command's export was remembered by me in the same bash process).
  ✅ State physically inherited by the same resident bash: directory and ENV_KEY are both still there

This is the entire truth of "PTY state inheritance". The previous command cd /tmp/harness_workspace && export ENV_KEY=JDK25 didn't die inside that process — because there was never "that process"; there is only one bash that has been alive since the opening. pwd prints its physical working directory; $ENV_KEY reads its real environment variable. If you used bash -c to start a new process for each command, this line would never print JDK25.

Step 3: 100,000 lines of logs, anti-explosion handled by ObservationTrimmer

──────── Act 3 · Print 100,000 lines of logs ────────
[user] Help me print 100,000 lines of logs
  [llm] Decision → ```bash for i in $(seq 1 100000); do echo "log line $i"; if [ "$i" = "50000" ]; then echo …
  [controller] Hit bash action, virtual thread submitted for execution…
  [observation] run-3 exit=0 920ms(stdout 100001 lines, trimmed by ObservationTrimmer)
      | [ObservationTrimmer] retained head40 + tail40 + key1 lines, trimmed 99920/100001 lines total
      | log line 1
      | log line 2
      | … 83 lines total, 80 lines omitted

100,001 real log lines, before entering Context, trimmed to head40 + tail40 + key1 — that 1 key line is exactly the simulated Error: simulated exception buried at line 50,000 of the log. Traceback/Error — not a single frame lost; the middle hundred thousand lines vanish into thin air. Without this trimmer, these 100,001 lines would directly blow up the Context window.

Step 4: Bad format → InvalidFormatObservation → model self-corrects

──────── Act 4 · Bad format → InvalidFormatObservation → Self-correction ────────
[user] Check if the environment is normal
  [llm] Decision → Okay, let me check the environment: ```bash ls -la /tmp/harness_workspace
  [controller] ⚠ InvalidFormatObservation → Code block fence not closed (missing closing ```) (written back to event stream, waiting for model self-correction)
  [llm] Decision → Sorry, the code block fence wasn't closed just now. Resending: ```bash ls -la /tmp/harness_workspace ```
  [controller] Hit bash action, virtual thread submitted for execution…
  [observation] run-4 exit=0 110ms(stdout 3 lines)
      | total 0
      | drwxr-xr-x@  2 jobslee  wheel   64 Aug 28 00:30 .
      | drwxrwxrwt  10 root     wheel  320 Aug 28 00:31 ..

  [assistant] Environment check complete, directory structure normal: total 0 drwxr-xr-x@  2 jobslee  wheel   64 Aug 28 00:30 . drwxrwxrwt  10 ro…
  ✅ Model received InvalidFormatObservation, self-corrected, and completed the check

Look at these four lines; they are the complete performance of the fourth pillar: the model's first output is a bad format with an unclosed fence → the parser didn't crash, but degraded into an InvalidFormatObservation (⚠ Code block fence not closed) written back to the event stream → the model reads this observation, apologizes, and re-sends the correct format → execution succeeds, environment check complete. Not a single line of exception stack trace; the Agent repaired itself.

The closing event stream summary perfectly demonstrates that this Agent's entire behavior is just events entering and leaving the stream:

-------- Event Stream Summary --------
EventStream total 17 events: 4 CmdRunAction / 4 CmdOutputObservation / 1 InvalidFormatObservation / 8 text messages
✅ HarnessDemo all assertions passed

Test Safety Net: 28 Assertions, All Green

---- TestHarness Result: Passed 28 / 28 ----

Covering sentinel protocol exit codes (false → 1), precise stdout/stderr separation (sentinel doesn't leak), cd/export cross-execute inheritance, 100,000-line trimming (Traceback mid-section not lost), parser three-way degradation (including wrong tags like ```she bash), sleep 5 with 300ms timeout self-healing, and pid unchanged across three executes — that statement "it's always been the same bash" has hard evidence.


6. Summary

6.1 Four-Chapter Column, Building the Complete Agent Blueprint

Now stack the four chapters together; a complete panoramic view of Agent architecture surfaces — each chapter hands over one key; the four keys combined form an Agent that "can think, doesn't run wild, remembers, and can work":

Dimension 01 · Swarm (ReAct) 02 · LangGraph (FSM) 03 · MemGPT/Letta (OS Memory) 04 · OpenHands (Harness)
Core abstraction One while loop + handoff One graph + State + reducer Memory tiering + self-edit tools Long-lived sandbox + sentinel protocol + event stream
Memory context_variables free dict State (snapshotable to disk) Core / Recall / Archival three tiers Shell process state + trimmed logs
Who controls the loop Model (tool_calls) Graph structure + conditional edges Model (heartbeat) + rules Event stream + controller
What if context explodes No solution, brute-force stuffing Checkpointer / compression Paging + SystemAlert self-cleaning Head-tail + key-line trimming
What if it does something bad No physical consequence (also can't work) None None Container isolation + timeout self-healing + degrading self-correction
The key this chapter hands over Loop Determinism Self-evolution Physical hands and feet

One sentence to string these four chapters together:

**Swarm proved the Agent's minimal skeleton is "the loop";

LangGraph proved "control flow" can be an explicit graph;

MemGPT proved "memory" can be tiered and self-evolving like an operating system;

And OpenHands proved the final puzzle piece — an Agent's true value lies not in how well it thinks, but in whether it can safely, continuously, without breaking the machine, turn thoughts into changes in the physical world.**

The complete anatomy of a mature Agent is these four systems installed into the same body:

image.png

6.2 What Else OpenHands Does

Like the previous chapters, list what the industrial-grade OpenHands has that we didn't re-implement — if you really want to go to production, these are exactly the missing pieces:

Facility not built What it is in real OpenHands Problem it solves
Real container isolation DockerWorkspace: one container running a pre-built agent-server image per session (openhands-workspace/.../docker/workspace.py) We run bash locally; "breaking the world" only isolates the process, not the filesystem
Real PTY allocation tmux pane (terminal/tmux_terminal.py) — the resident tmux session is that real PTY, supporting interactive programs Our long-lived connection is pipe-simulated; programs needing a TTY like vim/htop can't fit
PS1 JSON metadata Sentinel hidden in bash's PS1; exit_code defaults to/parse-fails to -1; when command unfinished returns -1, can send empty command to fetch logs, is_input=true to feed STDIN, C-c to interrupt Our echo ___HARNESS_END___ $? only carries exit code, no metadata like pid / working directory
Output trimming + save to disk maybe_truncate: trim middle, keep head and tail + <response clipped> hint + full output saved to file for continued reading We only trim in memory; the trimmed hundred thousand lines are truly gone
Process tree management & SIGINT Timeout graceful degradation: SIGINT first, then SIGKILL, preserving the scene We directly destroyForcibly; brutal but clean
Log destination audit Events fully persisted to database, replayable, auditable, diffable Our history only lives inside this process
Multi-sandbox parallelism One isolated container per session, horizontally scalable We have one process, one mouth

Now the four keys are complete:

Building an Agent, in the end, is not magic; it's filling in the physical chasm between "thinking" and "landing", section by section, with engineering.